iTranslated by AI
[Flutter] Redesigning draw_your_image for a Declarative API
I have updated the draw_your_image package, which provides hand-drawing functionality, for the first time in 5 years.
It was originally just a package I made as a "trial," so I didn't have many plans for adding features. Even when I needed hand-drawing functionality myself, I used other packages like scribble. However, I frequently encountered situations where existing hand-drawing packages were difficult to code with.
While wondering what kind of API design could solve this, I vaguely thought, "What if I go back to Flutter's 'declarative' coding principles?" I then implemented that idea in this update.
I briefly mentioned this in the README.md, but I felt the explanation alone didn't fully convey my intent. So, I would like to summarize it in more detail in this article.
I hope the content of this article helps you when thinking about API designs for packages or widgets shared within your projects.
"Imperative" Design Using Controllers
Like the previously mentioned scribble or the popular signature which has many likes, hand-drawing packages often feature an API design that uses a Controller to operate "imperatively."
Here is an image of what the code looks like:
// Create a controller
final controller = DrawController();
...
// Pass the controller to the Widget to link them
Draw(
controller: controller,
),
...
// Execute processes "imperatively" using the controller
controller.clear(); // Clear
controller.setMode(Mode.erasor); // Switch to eraser mode
This is convenient as it feels similar to Flutter's standard TextField, but it becomes quite troublesome when dealing with more complex use cases, especially state management linked with features outside the package.
Let's think more about that point.
Considering Integration with Standard Flutter Widgets
For example, imagine a "Signature" screen like the following:
- A hand-drawn "Signature" field.
- A "I agree to the terms" checkbox.
- A submit button that is active only when both the "Signature" and "I agree to the terms" have been input.
For example, if you were to create this screen with a StatefulWidget, the implementation would look something like this. (This is just an example.)
class _SignaturePageState extends State<SignaturePage> {
// Controller for the hand-drawing package
final _controller = DrawController();
// Checkbox state
bool _isChecked = false;
// Submittable if something is drawn and the box is checked
bool get _canSubmit => _controller.strokes.isNotEmpty() && _isChecked;
Future<void> _submit() async {
// ...Submission process
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Draw(
controller: _controller,
),
Checkbox(
value: _isChecked,
onChanged: (value) {
setState(() => _isChecked = value);
},
),
ElevatedButton(
onPressed: _canSubmit ? _submit : null,
),
],
)
}
}
So, will the code above work correctly?
The answer is no. With this, even if the user draws something, a rebuild won't be triggered at that moment. The ElevatedButton won't be regenerated with the latest value, and it won't become active until a rebuild is triggered for some other reason.
To solve this problem, depending on how the package is made, you must detect changes in the input state and call an empty setState(), for example, like this:
// Register a listener to the hand-drawing package controller
final _controller = DrawController()..addListener(() {
// Empty setState just for rebuilding
setState(() {});
});
...
// Alternatively, some packages are used by rebuilding via a callback like this
Draw(
controller: _controller,
onChanged: (stroke) {
// Calling empty setState in the callback
setState(() {});
}
),
As shown, you end up calling setState() just to trigger a rebuild. However, in an API design used via a Controller like this, in many cases, the hand-drawing state is managed inside the package, so there is no state on our side (the user of the package) that needs updating. Therefore, you end up writing code like setState(() {})—an empty call that is hard to understand at a glance.[1]
Some of you might have experience writing similar code when handling a TextField. Regardless of whether it's a package or not, there is a problem where imperative UI construction using a Controller is difficult to mix with Flutter's inherently "declarative" UI construction.
Thinking About undo/redo
Taking it a step further, let's consider the implementation of undo/redo.
In the declarative UI construction mindset, undo/redo can actually be implemented very simply. You can manage snapshots of the state in a List, as shown below, and then update the state by popping them when you want to undo.
// Current state
PageState _currentState = PageState();
// Accumulated states
List<PageState> _undoStack = [];
List<PageState> _redoStack = [];
// State update process
void updateState(PageState newState) {
setState(() {
// Save current state to undo stack
_undoStack.add(_currentState);
// Update current state
_currentState = newState;
// Generally clear redo stack when there is a new operation
_redoStack.clear();
});
}
// undo process
void undo() {
setState() {
// Remove the last (i.e., previous state) from the undo stack
final newState = _undoStack.removeLast();
// Save current state to stack so it can be redone
_redoStack.add(_currentState);
// Update current state
_currentState = newState;
}
}
However, the story changes when an imperative undo process like controller.undo() is mixed in. This is because you have to consider whether the thing to undo right now is the state you manage yourself, or the package's internal state updated via the controller.
States that should be pushed onto the stack are also difficult to combine into a single object like PageState. Even if you manage to do so cleverly, it's not as simple as just popping them and rebuilding to update the current state when undoing.
Out of necessity, I used a method of managing a List of "functions that hold the processes to occur during undo/redo."
// Record to manage functions called during undo/redo as a pair
typedef UndoRedoAction = ({VoidCallback undo, VoidCallback redo});
// Accumulated actions
List<UndoRedoAction> _undoStack = [];
List<UndoRedoAction> _redoStack = [];
// Drawing update process
void updateDraw() {
setState(() {
_undoStack.add((undo: _controller.undo, redo: _controller.redo);
_redoStack.clear();
});
}
void updateCheckbox(bool value) {
setState(() {
_undoStack.add((
undo: () {
setState(() => _isChecked => !value);
},
redo: () {
setState(() => _isChecked => value);
},
));
_redoStack.clear();
_isChecked = value;
});
}
// undo process
void undo() {
setState(() {
// Remove the last undo/redo function pair from the undo stack
final action = _undoStack.removeLast();
// Call undo
action.undo();
// Save action with redo process so it can be redone
_redoStack.add(action);
});
}
The more complex the operations become, the more you find yourself thinking, "Wait, if this operation happened, what process should run when I want to undo? And what about the redo process to bring it back?" This leads to oversights and misunderstandings of logic, and this mechanism often becomes unmanageable.
Essentially, I evaluate the "typical" API design using a Controller as being good for using that package standalone, but prone to becoming difficult when trying to integrate it with more sophisticated features.
API Design of draw_your_image
Now, let's talk about what draw_your_image is like.
That said, I didn't invent some groundbreakingly wonderful API design; what I want to write here is how I re-evaluated its usability by looking at the Checkbox widget, which has already appeared in this article, as a reference.
Checkbox's API Design
Let's take another look at how Checkbox is used.
Checkbox(
value: _isChecked,
onChanged: (value) {
setState(() => _isChecked = value);
},
);
We tend to perceive a checkbox as a widget whose "state changes when you check it." However, as you can see from how it's used, the "checked state" is not inside Checkbox, but is something the user specifies via the value argument.
Therefore, the flow of change for a checkbox's state and UI is not:
- Tap
- Internal state and UI change
- The change is notified via a callback
But rather:
- Tap
- The changed value is passed via a callback
- Rebuild with the changed value
- UI changes
The key point is that it's not "called because it changed," but rather we change the value we pass based on receiving the callback. If you wanted to (though there might not be many such situations), you could flexibly implement a mechanism like "change the checked state only if tapped twice" depending on your own implementation.
In other words, Checkbox is a (mostly) stateless widget that doesn't hold state internally[2], and the UI is always determined by the value we provide. Also, changing the UI is as simple as rebuilding with the value passed to value. There's no need to prepare a controller like a CheckboxController.
In Flutter, various "touch-and-change" widgets like Slider and FilterChip are built in the same way, forming a pattern for constructing UI with declarative coding.
Slider(
value: _value, // Value to be displayed in the UI
onChanged: (value) {
// Called every time the slider is moved
setState(() => _value = value);
},
),
...
FilterChip(
selected: _selected, // Current selection state
onSelected: (bool selected) {
// Called when tapped
setState(() => _selected = selected);
},
),
draw_your_image: Before/After
I re-evaluated the usability of the Draw widget in draw_your_image based on this idea. Specifically, the design is now: passing a list of Stroke objects (representing each individual line) to Draw renders them, and the result of user interaction is received via a callback.
/// Drawing state
List<Stroke> _strokes = [];
...
Draw(
strokes: _strokes, // Pass the Stroke data you want to draw
onStrokeDrawn: (stroke) {
// When the user draws a line, that data is passed via a callback,
// so you add it to your own _strokes and rebuild.
setState(() => _strokes.add(stroke));
},
),
You can see that it's very similar to the Checkbox mentioned earlier. In the example above, I assume code where _strokes is managed by a StatefulWidget, but of course, it's no problem to manage it with Riverpod or flutter_hooks.
Furthermore, I have removed the previously available DrawController. This eliminates the confusion of "I want to change the color or thickness; should I change the value passed to Draw? Or is there an update method in DrawController?" and makes it a simple design where "operations on Draw are always achieved by changing its arguments."
Since there is no controller, you don't have to worry about situations like "I want to load past data from a database and draw it with Draw, but is it okay to let a Provider hold a controller...?"
Because a controller is unnecessary, you can also simply extract and verify only the values held by Stroke when writing test code.
The Importance of "Not Managing State Internally"
I believe the biggest advantage of this design is that the location of the state is not dispersed.
If a shared Widget manages its state internally, it inevitably causes inconveniences when you want to manage it consistently with other states. This is because when you want to get or update that value at some point, you are forced to write "imperative" code just for that part from the outside.
If that process is within an imperative procedure like "executing when a button is pressed," it might not be a big issue. However, in cases where you want to change the state in association with other states, such as "making the line color lighter while a checkbox is checked," declarative and imperative processes end up being mixed. This leads to decreased readability and an increased risk of bugs, as shown below:
bool _isChecked = false; // Checkbox flag
bool _lastIsChecked = false; // Cache for detecting changes
@override
Widget build(BuildContext context) {
// Want to change color when the checked state changes
// However, calling imperative processes on the controller every rebuild is risky,
// so we delay the process until the build is complete.
if (_lastIsChecked != isChecked) {
WidgetsBindings.instance.addPostFrameCallback((_) {
_lastIsChecked = isChecked;
if (_isChecked) {
_controller.updateStroke(
// Process to change color to gray
),
} else {
_controller.updateStroke(
// Process to change color back to normal
),
}
});
}
return Column(
// Omitted
),
}
The ease of use is obvious when compared to doing this entirely declaratively:
List<Stroke> _strokes = []; // Drawn lines
bool _isChecked = false; // Checkbox flag
@override
Widget build(BuildContext context) {
// Determine color based on the value of _isChecked
final effectiveStrokes = _strokes.map((stroke) {
return stroke.copyWith(color: _isChecked ? Colors.grey : stroke.color);
}).toList();
return Column(
children: [
Draw(
// Just create and pass the data to be displayed
strokes: effectiveStrokes,
),
]
),
}
By following the concepts of ephemeral state and app state and being mindful to keep state that only needs to be handled within a Widget internal, while managing other state outside the Widget, you can improve the usability when designing shared Widgets.
In that sense, if you don't need to handle the hand-drawing data itself (i.e., if it's enough for the drawing to just be displayed on the screen), packages that rely on a Controller are convenient. On the other hand, if you want to link hand-drawing data with other states or features, I think considering draw_your_image would be an appropriate perspective when making a selection.
Trade-offs
However, it's not as if this design is superior in every situation.
As you may have noticed by now, features that could be called "declarative" functionalities, such as undo/redo, have been removed from the draw_your_image package. Instead, the design now expects the app using the package to implement these features. For the same reason, functionalities like clearing the drawing content and image conversion were also removed from the package in this update.
While it is true that this change increases the burden on the user, the design prioritizes the ability to handle data flexibly according to the user's requirements.
Since all the data resides on the user side and the package simply renders according to that data, users can implement a wide range of hand-drawing expressions by implementing their own data processing logic.

Furthermore, since we are in an era where AI assistance is sufficiently practical, I believe that the increased burden can be easily covered with the help of AI. To ensure more accurate code generation, I have prepared a file called AI_GUIDE.md in the package repository, which describes the usage instructions for AI in detail.
By having the AI read this file via its URL or by temporarily copying and pasting it into a location in your project for the AI to read, the goal is for the AI to make more accurate judgments by including detailed information in its context.[3] Please give it a try with "vibe coding."
Summary
Additionally, draw_your_image provides mechanisms to customize various small details, following the philosophy of "if it's going to be used for complex requirements anyway."
For example, the logic for hit detection between strokes, smoothing logic, and handling for each input device can all be customized by creating your own functions and passing them to Draw.
However, discussing these points would make this article even longer, so I hope to write about them in another post.
Unlike other packages I've created, such as animated_to for animations or crop_your_image for image cropping, there may not be many apps that require "hand-drawing functionality." However, I would be very happy if you could keep in the back of your mind that "for complex requirements involving hand-drawing, use draw_your_image."
Also, when designing Widgets to be shared within a project, I hope that being mindful of what I've written in this article might help you create better designs.
-
Because this is a
StatefulWidget, the method of "calling an emptysetState()" can still be used. However, in the case of aHookWidget, you might end up preparing a meaningless state with a meaninglessuseState(), making it even more troublesome. ↩︎ -
In reality,
Checkboxis aStatefulWidgetbecause it handles state management for animations and such. ↩︎ -
If there is a more appropriate way to do this, please let me know. ↩︎
Discussion
記事作成ありがとうございます!
draw_your_image の概要をSNSで見かけていて気になっていたため、記事を読ませていただきました💡
パッケージの設計方針を知れるだけでなく、プロジェクト内の共通Widgetもどう構築しようかすごい参考になりました✨️