GardenMobile & interfaces

A draggable circular-crop experiment in Flutter

An older overlay and capture implementation with distinct display, geometry, and output-file boundaries.

A circular crop interaction combines several jobs: display a background image, draw an overlay with a transparent opening, move the selection, and convert the selected region into output bytes.

The original experiment used draggable normalized coordinates and Flutter rendering capture:

Read the historical crop widget
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:nft/services/safety/base_stateful.dart';
import 'package:nft/widgets/p_appbar_transparency.dart';
import 'package:path_provider/path_provider.dart';

class WCircleCrop extends StatefulWidget {
  @override
  _WCircleCropState createState() => _WCircleCropState();
}

class _WCircleCropState extends BaseStateful<WCircleCrop> {
  final GlobalKey _backgroundImgKey = GlobalKey();
  final GlobalKey _circleViewKey = GlobalKey();
  Uint8List imgBytes;

  ByteData _readFromFile(File file) {
    final Uint8List bytes = file.readAsBytesSync();
    return ByteData.view(bytes.buffer);
  }

  Future<File> _writeToFile(ByteData data) async {
    // https://pub.dev/packages/path_provider
    final String dir = (await getTemporaryDirectory()).path;
    final String filePath = '$dir/tempImage.jpg';
    final ByteBuffer buffer = data.buffer;
    return File(filePath).writeAsBytes(
        buffer.asUint8List(data.offsetInBytes, data.lengthInBytes));
  }

  Future<ui.Image> _loadImageSource(File imageSource) async {
    // ByteData data = await rootBundle.load(asset);
    final ByteData data = _readFromFile(imageSource);
    final ui.Codec codec =
        await ui.instantiateImageCodec(data.buffer.asUint8List());
    final ui.FrameInfo fi = await codec.getNextFrame();
    return fi.image;
  }

  Future<Uint8List> _capturePng() async {
    try {
      final RenderBox circleBoundary =
          _circleViewKey.currentContext.findRenderObject() as RenderBox;
      final Size circleBoundarySize = circleBoundary.size;
      final Offset globalTopLeft = circleBoundary.localToGlobal(Offset.zero);

      final RenderRepaintBoundary backgroundBoundary =
          _backgroundImgKey.currentContext.findRenderObject()
              as RenderRepaintBoundary;

      // Background to image
      const double scale = 2.0;
      final double outputW = circleBoundarySize.width * scale;
      final double outputH = circleBoundarySize.height * scale;
      final double srcLeft = globalTopLeft.dx * scale;
      final double srcTop = globalTopLeft.dy * scale;
      final ui.Image image =
          await backgroundBoundary.toImage(pixelRatio: scale);

      // Edit background image
      final ui.PictureRecorder recorder = ui.PictureRecorder();
      final ui.Rect rect = Rect.fromLTWH(0, 0, outputW, outputH);
      final Canvas canvas = Canvas(recorder, rect);
      final Paint paint = Paint();

      // Clip circle
      final Path path = Path()..addOval(Rect.fromLTWH(0, 0, outputW, outputH));
      // Requires save and restore canvas to local clipPath
      canvas.save();
      canvas.clipPath(path);
      canvas.drawImageRect(
          image,
          Rect.fromLTWH(srcLeft, srcTop, outputW, outputH),
          Rect.fromLTWH(0, 0, outputW, outputH),
          paint);
      canvas.restore();

      // Finish edit, convert to image render
      final ui.Picture picture = recorder.endRecording();
      final ui.Image img =
          await picture.toImage(rect.width.toInt(), rect.height.toInt());
      final ByteData byteData2 =
          await img.toByteData(format: ui.ImageByteFormat.png);
      final Uint8List pngBytes2 = byteData2.buffer.asUint8List();
      final String bs642 = base64Encode(pngBytes2);
      print(pngBytes2);
      print(bs642);
      setState(() {
        imgBytes = pngBytes2;
      });
      return pngBytes2;
    } catch (e) {
      print(e);
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return PAppBarTransparency(
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          RepaintBoundary(
            key: _backgroundImgKey,
            child: Image.network(
              'https://wallpaperplay.com/walls/full/e/5/3/13586.jpg',
              fit: BoxFit.cover,
            ),
          ),
          ColorFiltered(
            // This one will create the magic
            colorFilter: ColorFilter.mode(
                Colors.black.withOpacity(0.8), BlendMode.srcOut),
            child: Stack(
              fit: StackFit.expand,
              children: <Widget>[
                Container(
                  decoration: const BoxDecoration(
                      color: Colors.black,
                      // This one will handle background + difference out
                      backgroundBlendMode: BlendMode.dstOut),
                ),
                _WDraggable(
                  child: Container(
                    key: _circleViewKey,
                    height: 200,
                    width: 200,
                    decoration: BoxDecoration(
                      color: Colors.white,
                      borderRadius: BorderRadius.circular(100),
                    ),
                  ),
                ),
                const Center(
                  child: Text(
                    'Hello World',
                    style: TextStyle(fontSize: 70, fontWeight: FontWeight.w600),
                  ),
                )
              ],
            ),
          ),
          if (imgBytes != null)
            IgnorePointer(
              child: Container(
                alignment: Alignment.bottomCenter,
                child: Image.memory(
                  imgBytes,
                  height: 200,
                  width: 200,
                  fit: BoxFit.contain,
                ),
              ),
            ),
          Center(
            child: RaisedButton(
              onPressed: () {
                _capturePng();
              },
              child: const Text('Capture'),
            ),
          )
        ],
      ),
    );
  }
}

/// Draggable widget
class _WDraggable extends StatefulWidget {
  const _WDraggable({Key key, this.child, this.valueChanged}) : super(key: key);

  final Widget child;
  final ValueChanged<Offset> valueChanged;

  @override
  _WDraggableState createState() => _WDraggableState();
}

class _WDraggableState extends State<_WDraggable> {
  ValueNotifier<Offset> valueListener = ValueNotifier<Offset>(const Offset(1, 1));

  @override
  void initState() {
    valueListener.addListener(_notifyParent);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Builder(
      builder: (BuildContext context) {
        final GestureDetector handle = GestureDetector(
          onPanUpdate: (DragUpdateDetails details) {
            final double dx = (valueListener.value.dx +
                (details.delta.dx / context.size.width))
                    .clamp(0.0, 1.0)
                    .toDouble();
            final double dy = (valueListener.value.dy -
                (details.delta.dy / context.size.height))
                    .clamp(0.0, 1.0)
                    .toDouble();
            valueListener.value = Offset(dx, dy);
          },
          child: widget.child,
        );

        return AnimatedBuilder(
          animation: valueListener,
          builder: (_, Widget child) {
            return Align(
              alignment: Alignment(valueListener.value.dx * 2 - 1,
                  1 - valueListener.value.dy * 2),
              child: child,
            );
          },
          child: handle,
        );
      },
    );
  }

  // Notify change to parent
  void _notifyParent() {
    if (widget.valueChanged != null) {
      widget.valueChanged(valueListener.value);
    }
  }
}

Keep display and file output separate

The visible hole is not proof that the exported pixels use the same coordinate space. Image fit, device pixel ratio, capture bounds, and selection position need to agree. The temporary filename’s extension must also match the bytes actually encoded.

The listing depends on application-specific base-state and app-bar classes. Image decoding, native image/byte-resource lifetime, and temporary-file cleanup need their own review. No image was cropped or written by executing this example for this revision.

Source: original Gist revision.

← Back to the garden