Take a ‘Screenshot’ Of a Certain Widget in Flutter

Agung Surya
5 min readNov 30, 2018

--

PROLOGUE
It’s been a long time since my last post about Flutter. Now I’m back. I’m happy that one of my articles is mentioned in Flutter’s official docs.

Flutter oficial docs

To be honest, it motivates me to start writing about Flutter again in my spare time.

THE IDEA
I’m interested on this comic remake page. It has a template comic where the balloon(s) can be filled with text the user wants to fill. Then the user can download it right away. I challenged myself to re-create that on Flutter. Let’s have some fun!

GET STARTED
From the original website above, I got some informations:

  1. The picture is square and its dimension is 800 x 800.
  2. There are some coordinates data for the input texts’ position. In above picture, there are 2 editable balloons. So there are two data:
[{"y":30.23333740234375,"x":410.16668701171875,"width":247,"height":106},{"y":472.23333740234375,"x":74.16668701171875,"width":178,"height":49}]

The problem is that the coordinate is following the picture’s original size. So I’ve to perform a downscale and keep the aspect ratio. Here, I prepare a class to hold the coordinates data:

class Datum {
final int height, width, x, y;

Datum({this.height, this.width, this.x, this.y});
}

Then, let’s create the main class. At the first attempt, I thought about using the Canvas for this case. The idea was to draw the image and the inputted texts to the canvas. Sounds good, doesn’t it? But after more research, I came up with another simpler approach. There is a class in Flutter named RepaintBoundary . Based on the documentation, it creates a separate display list for its child. We can make use of this to create a ‘duplicate’ of a widget. Here, I use the RepaintBoundary and set a key to it.

class Remake extends StatefulWidget {
@override
_RemakeState createState() => _RemakeState();
}
class _RemakeState extends State<Remake> {
GlobalKey previewContainer = new GlobalKey();
int originalSize = 800;

Image _image = Image.network(
"https://www.tahilalats.com/medias/c9befeb0-18c3-4344-a336-0db4996a76f4-2018-08-06-13-34-36-237895-800.jpeg",
);
Image _image2;

@override
Widget build(BuildContext context) {
double size = MediaQuery.of(context).size.width;
double factor = size / originalSize;
Datum data = Datum(
height: (106 * factor).floor(),
width: (247 * factor).floor(),
x: (410.16668701171875 * factor).floor(),
y: (30.23333740234375 * factor).floor(),
);
Datum data2 = Datum(
height: (49 * factor).floor(),
width: (178 * factor).floor(),
x: (74.16668701171875 * factor).floor(),
y: (472.23333740234375 * factor).floor(),
);
return Container(
width: size,
padding: EdgeInsets.only(top: 40.0),
child: SingleChildScrollView(
child: Column(
children: <Widget>[
RepaintBoundary(
key: previewContainer,
child: Stack(
children: <Widget>[
_image,
Positioned(
top: data.y.toDouble() - 15,
left: data.x.toDouble(),
child: Container(
width: data.width.toDouble(),
height: data.height.toDouble(),
child: TextField(
maxLines: 3,
decoration: InputDecoration(
border: InputBorder.none,
hintText: "Write here..",
),
style: TextStyle(
color: Colors.black,
),
),
),
),
Positioned(
top: data2.y.toDouble() - 15,
left: data2.x.toDouble(),
child: Container(
width: data2.width.toDouble(),
height: data2.height.toDouble(),
child: TextField(
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
hintText: "Write here..",
),
style: TextStyle(
color: Colors.black,
),
),
),
),
],
),
),
RaisedButton(
child: Text('Download'),
onPressed: (){
FocusScope.of(context).requestFocus(FocusNode());
takeScreenShot();
},
),
_image2 != null ? _image2 : Container(),
],
),
),
);
}

I get the device’s width:

double size = MediaQuery.of(context).size.width;

Then, I get the scale ratio of the width compared to image’s original size:

double factor = size / originalSize;

Finally I perform the downscale to the coordinates :

...
Datum data = Datum(
height: (106 * factor).floor(),
width: (247 * factor).floor(),
x: (410.16668701171875 * factor).floor(),
y: (30.23333740234375 * factor).floor(),
);
Datum data2 = Datum(
height: (49 * factor).floor(),
width: (178 * factor).floor(),
x: (74.16668701171875 * factor).floor(),
y: (472.23333740234375 * factor).floor(),
);
...

Later I apply the position to the corresponding widgets:

...
Positioned(
top: data.y.toDouble() - 15,
left: data.x.toDouble(),
child: Container(
width: data.width.toDouble(),
height: data.height.toDouble(),
child: TextField(
maxLines: 3,
decoration: InputDecoration(
border: InputBorder.none,
hintText: "Write here..",
),
style: TextStyle(
color: Colors.black,
),
),
),
),
Positioned(
top: data2.y.toDouble() - 15,
left: data2.x.toDouble(),
child: Container(
width: data2.width.toDouble(),
height: data2.height.toDouble(),
child: TextField(
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
hintText: "Write here..",
),
style: TextStyle(
color: Colors.black,
),
),
),
),
...

GENERATE AND DOWNLOAD THE IMAGE
I provide a button to generate and download the image. Word generate here means to ‘draw’ the _image and the texts inputted to a variable named _image2 .

...
RaisedButton(
child: Text('Download'),
onPressed: (){
FocusScope.of(context).requestFocus(FocusNode());
takeScreenShot();
},
)
_image2 != null ? _image2 : Container(),_image2 != null ? _image2 : Container(),
...

Here is the method:

takeScreenShot() async {
RenderRepaintBoundary boundary =
previewContainer.currentContext.findRenderObject();
double pixelRatio = originalSize / MediaQuery.of(context).size.width;
ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png);
Uint8List pngBytes = byteData.buffer.asUint8List();
setState(() {
_image2 = Image.memory(pngBytes.buffer.asUint8List());
});
final directory = (await getApplicationDocumentsDirectory()).path;
File imgFile = new File('$directory/screenshot.png');
imgFile.writeAsBytes(pngBytes);
final snackBar = SnackBar(
content: Text('Saved to ${directory}'),
action: SnackBarAction(
label: 'Ok',
onPressed: () {
// Some code
},
),
);

Scaffold.of(context).showSnackBar(snackBar);
}

Thing to mention is that we have to also set the pixel ratio for the image’s quality.

...
double pixelRatio = originalSize / MediaQuery.of(context).size.width;
ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
...

To download the image, I need the path of the directory. This time, I use path_provider package to help me.

final directory = (await getApplicationDocumentsDirectory()).path;
File imgFile = new File('$directory/screenshot.png');

After you press the button, you should see a new image under it. Plus, it’s automatically downloaded to your device.

Final result

NOTES
Since I’m using someone else’s website as an example, I won’t tell you how to get the coordinates information. I also won’t make the example to be more dynamic. For instance, there are two hardcoded text fields in this example. In its real application, there can be various number of text fields, depend on the coordinates provided by the application.

Thanks for reading this post. Have a good day!

--

--

Agung Surya

A frontend developer. A fan of ReactJS and Google Flutter. Want to hire me or say hi? Feel free to contact me at dualboot63@gmail.com