iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔬

Deep Dive into the Architecture of crop_your_image: A Flutter Image Cropping Package

に公開

crop_your_image is an image cropping package for Flutter apps.

https://pub.dev/packages/crop_your_image

I've been maintaining it on a small scale since starting development about 3 or 4 years ago. Recently, on December 13, 2024, I finally released version 2.0.0, which included various bug fixes and new feature requests.

In this 2.0.0 release, I significantly overhauled the internal design and performed refactoring, which I'd like to introduce in this article.

I hope this serves as a reference for package development, as well as for those who want to understand how crop_your_image works by reading the code or who wish to submit pull requests for new features.

About crop_your_image

Before that, let me briefly introduce what kind of package crop_your_image is.

When it comes to image cropping packages for Flutter app development, image_cropper is probably one of the most well-known ones.

https://pub.dev/packages/image_cropper

While image_cropper enables image cropping with just a single step of code, its functionality is achieved by navigating to a screen provided natively. Consequently, it cannot be used integrated with Flutter widgets.

On the other hand, crop_your_image provides a cropping UI built entirely with widgets by placing a widget called Crop .

Crop respects Flutter's layout system, determines its size based on the given Constraints, and calculates the necessary coordinates. In other words, you can place Crop anywhere. It works perfectly whether expanded across the entire screen, placed small as part of a form, or used within bottom sheets or dialogs.

The concept of crop_your_image is to provide a seamless cropping experience for users by allowing you to freely configure the UI to match the app's design.

Just like Text and TextEditingController, its usage is realized through Crop and its controlling CropController, aiming for a design that can be used without learning new concepts.

// Prepare CropController in State field, etc.
final _controller = CropController();

Column(
  children: [
    Expanded(
    // Place Crop
      child: Crop(
        controller: _controller, // Set CropController
        image: _imageData,
      ),
    ),
    ElevatedButton(
      onPressed: () => controller.crop(), // Execute cropping
      child: Text('Crop!'),
    ),
  ],
),

Thanks to this design that follows Flutter's philosophy, the distinction from image_cropper has become clear, and I'm proud to say it's become a package that many people use. [1]

That's the kind of package it is.

The Importance of Architecture in Packages

Before discussing the architecture of crop_your_image, I want to address whether the consideration of "architecture" is even necessary for a "package."

As you might imagine, packages consist of a much smaller amount of code compared to applications. It feels like simply naming and publishing some common widget or logic you created in regular app development. [2]

Furthermore, package development is mostly an individual effort. Unlike work projects developed with team members, you could say that, ultimately, "as long as I understand it, that's fine."

Nevertheless, with every major version up like 1.0.0 and 2.0.0, I have reviewed the architecture and performed refactoring for crop_your_image. The reason boils down to one thing: because there are people who send pull requests.

Package development is not just solo development, even if it seems that way. As long as the code is public on GitHub, there are people all over the world who read it and send pull requests for feature additions or bug fixes. And thanks to those PRs, the possibility arises to implement features that I wouldn't be able to handle on my own.

I don't have the time to talk to those people directly and explain the code. In most cases, they have to read and modify it on their own. When considering that, maintaining code that is as readable and easy to modify as possible can be seen as important for lowering the hurdle for PRs and making the package more active.

Beyond that, I also have personal motivations like wanting to actually shape what I've thought about or wanting to showcase it as an output. However, as a merit that doesn't depend on such personal motives, I believe "ease of submitting pull requests" stands out.

These are my thoughts on the importance of architecture in package development.

Architecture of crop_your_image

Now, let's look at the architecture of crop_your_image.

As mentioned earlier, the total amount of code is not that large. Therefore, it's not like there are many classes across multiple layers; you can just think of it simply in terms of "how the classes were divided." Since the word "architecture" might sound a bit too grand, I'll simply use the term "class design" from here on.

Class Design Concepts

I prioritized the following two points in the class design of crop_your_image:

  • Clearly separate image processing logic from the UI and make it replaceable.
  • Keep the coordinate calculation logic that forms the basis of the UI as independent of Widget classes as possible.

As an image cropping package, crop_your_image requires a great deal of calculation processing, such as calculating coordinates and zoom levels for users to freely select the cropping area, and converting the adjusted cropping range on the display into coordinates based on the actual image size.

If these processes were all lumped into a single StatefulWidget class, the code would naturally become difficult to read, making it hard to tell where the calculation logic ends and the UI construction code begins.

Furthermore, after calculating the coordinates of the user-determined cropping area, code to perform the target image processing using those coordinates is also necessary. Since I am not an expert in image processing, I believe it's very important to isolate code for "fields I'm not very familiar with" to make it easier to get help from others.

To summarize so far, crop_your_image's classes are divided into three roles:

  • Classes for constructing the UI
  • Classes for implementing logic such as coordinate and zoom calculations
  • Classes for performing image processing

crop_your_image overview

Let's look at each of these in detail, starting from the bottom.

Classes for Performing Image Processing

Image processing is handled by the ImageCropper class.

However, this is an abstract class. While it defines the "cropping flow," the concrete implementation of the cropping logic is handled by subclasses. When simplified, it has the following structure:

abstract class ImageCropper<T> {
  const ImageCropper();

  // Cropping process
  FutureOr<Uint8List> call() async {
    // Validation of the cropping area
    final error = rectValidator(original, topLeft, bottomRight);
    if (error != null) {
      throw error;
    }

    // Execute the cropping process. Note that the process differs depending on whether it is a rectangle or a circle.
    return switch (shape) {
      ImageShape.rectangle => rectCropper(),
      ImageShape.circle => circleCropper(),
    };
  }

  // Concrete implementations of validation and cropping are handled by subclasses.
  RectValidator<T> get rectValidator;
  RectCropper<T> get rectCropper;
  CircleCropper<T> get circleCropper;
}

As mentioned earlier, I am a complete amateur when it comes to image processing. Therefore, crop_your_image performs all image processing using the image package.

https://pub.dev/packages/image

However, there are people in the world who specialize in image processing but are not familiar with Flutter's UI. There might also be projects that want to use existing image processing assets or libraries that can be reused in native environments.

To accommodate such cases, crop_your_image makes ImageCropper replaceable.

Crop(
  controller: _controller,
  image: _imageData,
  // Replace with a concrete implementation of image processing
  cropper: MyCropper(),
),

The image package used in the default implementation has the benefit of working on all platforms, including Web and desktop, because it is written entirely in Dart. On the other hand, native performance may be better in some scenarios. I have provided this replacement mechanism to prepare for cases where someone might think, "The UI of crop_your_image is good, but I can't use it because of issues with image processing quality."

In doing so, I've also put some effort into ensuring flexibility while reducing the amount of code required for the replacement, such as making the base ImageCropper class independent of the image package and organizing common image and coordinate information needed by any implementation in the parent class.

Classes for Calculating Coordinates

There are many coordinate calculations in crop_your_image. If these were implemented directly within the Widget classes, the following inconveniences would occur:

  • It becomes hard to read.
  • It becomes hard to test.

"Hard to read" is self-explanatory. If Widget construction and coordinate calculations are mixed in a single file, it becomes difficult even for me to keep track of what is written where.

As for "hard to test," while packages have less code, they are used in far more frequencies and patterns than "common classes within an app." Therefore, it is crucial to verify whether each code fix impacts unintended areas across various patterns, but doing so manually is quite difficult.

Furthermore, people who submit pull requests for features or bug fixes won't necessarily check the behavior in all possible patterns. Since this isn't their job, they have limited time. Consequently, the importance of ensuring quality through test code (specifically, "confirming no changes in behavior") becomes even higher than in app development for work.

However, if you try to write test code indiscriminately, you'll find that testing logic involving the UI is difficult and costly, as I wrote previously.

The reason for separating coordinate calculations into a different class is to lower the hurdle for testing, increase the number of test patterns covered, and enable automatic verification of patterns that cannot be fully checked manually.

Coordinate Calculation and State Management

Now that it's been decided to separate the coordinate calculation class, how should the classes be divided?

In version 1.0.0 of crop_your_image, I prepared a class called Calculator that collected functions for calculations, but it was in a state where the "logic"—when to call those calculations and how to handle the results—still remained in the Widget.

Because of this, the logic couldn't be completely separated from the Widget. Also, since Crop has many values that need to be recalculated as the user zooms or moves the cropping area, doing all of that in the Widget still left it in a "hard to read" and "hard to test" state.

Therefore, CropEditorViewState is the class I separated by focusing on "state" and "declarative".

CropEditorViewState is an immutable class that holds all the values necessary for building the UI, and it has the following characteristics:

  • Receives the values that form the basis of the calculation in the constructor.
  • Provides values derived from those calculations using late final.

Whether it's for a package or general Flutter app development, the values finally needed in a Widget's build() method can be organized into the following four patterns:

    1. Values received via the Widget's constructor
    1. State generated inside that Widget
    1. Global state obtained from outside
    1. Values calculated from the above

In app development, most Widgets simply receive and use these appropriately without the code getting messy.

However, for a Widget like Crop, which provides various operations to users and programmers—such as zooming and moving images, adjusting the cropping area, and changing settings—complexity quickly arises from code where "a change there affects this over here" if these aren't well-organized. CropEditorViewState is what organizes this.

CropEditorViewState receives all of the aforementioned 1, 2, and 3 through its constructor. [3]

Then, by declaring other values calculated from those as late final, the logic ensures that the calculation is performed only once when the Widget accesses that field at the necessary timing, and the result is retained.

class CropEditorViewState {
  ReadyCropEditorViewState({
    required super.viewportSize,
    required this.imageSize,
    required this.cropRect,
    required this.imageRect,
    required this.scale,
    required this.offset,
  });

  /// Various values received in the constructor
  final Size viewportSize;
  final Size imageSize;
  final ViewportBasedRect cropRect;
  final ViewportBasedRect imageRect;
  final double scale;
  final Offset offset;

  /// Below are values derived from calculations
  /// Whether the image fits vertically against the given frame
  late final isFitVertically = imageSize.aspectRatio < viewportSize.aspectRatio;

  /// Calculation class that changes depending on whether it's horizontal or vertical
  late final calculator =
      isFitVertically ? VerticalCalculator() : HorizontalCalculator();

  /// Zoom rate to fit exactly within the frame
  late final scaleToCover = calculator.scaleToCover(viewportSize, imageRect);
}

By doing this, the build() method of Crop only needs to specify the required values from CropEditorViewState at the necessary timing. This eliminates the need for logic like "apply this calculation result here..." and allows you to focus simply on "re-creating CropEditorViewState whenever the state changes."

Also, since the many values representing the state are grouped in CropEditorViewState rather than being written alongside build() in the State class, the content of Crop can focus solely on Widget construction and event handling.

The basic concept of Flutter is "declarative."

For example, if you want to "recalculate Y because the state of X changed," instead of "remembering to" call the process to recalculate Y, you can simplify the build() method's code by considering a mechanism where "Y is automatically recalculated when X changes."

Classes for constructing the UI

With these efforts in place, there is nothing particularly special to mention regarding the UI.

It's simply a matter of updating CropEditorViewState as appropriate and using the values obtained from it to code "what the UI should be in this state." Since you can assume that the tedious coordinate calculation processes are already handled, you only need to focus on placing widgets, calling the correct callbacks when events occur, and re-creating CropEditorViewState. [4]

In the crop_your_image package, the class where this Crop is defined is also read to check the "specifications of Crop."

Therefore, it is more important to be able to get an overview of the usage documentation, how various arguments are handled, and what it looks like in different cases, rather than long and complex calculation processes.

In that sense, it's beneficial to set aside the "concrete" coordinate calculations and image processing.

Summary

That's all. Although some parts were explained in a simplified manner, I believe that reading this before diving into the code of crop_your_image will make it easier to understand how the image cropping functionality is implemented.

However, this doesn't mean every package should be designed this way. As you can see from reading this far, all the class divisions explained were based on the specific circumstances of crop_your_image.

With that in mind, I would be happy if this serves as a reference when you create another package.

If this article has piqued your interest in crop_your_image, please try using it and send pull requests for feature additions or bug fixes! (And please wait patiently for my response. Sorry!)

脚注
  1. According to pub.dev, it has about 45,000 downloads per month. Thank you. ↩︎

  2. Of course, the scale varies depending on the content of the package, but it is rare for a package to exceed the scale of app development itself. ↩︎

  3. Though, due to the nature of the app, there is no "3. Global state obtained from outside." ↩︎

  4. Although there is still various necessary code. ↩︎

GitHubで編集を提案

Discussion