Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active October 9, 2025 08:17
Show Gist options
  • Select an option

  • Save PlugFox/e94689b8e708c5ab191764506f5da089 to your computer and use it in GitHub Desktop.

Select an option

Save PlugFox/e94689b8e708c5ab191764506f5da089 to your computer and use it in GitHub Desktop.
Flutter Web Drop Zone
import 'dart:typed_data' show Uint8List;
import 'package:meta/meta.dart' show immutable;
import 'drop_zone_stream_vm.dart'
// ignore: uri_does_not_exist
if (dart.library.js_interop) 'drop_zone_stream_js.dart';
/// {@template drop_zone_event}
/// Events emitted by the [DropZone] stream.
/// {@endtemplate}
@immutable
sealed class DropZoneEvent {
/// {@macro drop_zone_event}
const DropZoneEvent({required this.dx, required this.dy});
/// The position of the event in the viewport.
/// Values are in logical pixels.
final int dx;
/// The position of the event in the viewport.
/// Values are in logical pixels.
final int dy;
T map<T>({
required T Function(DropZoneEvent$Enter event) enter,
required T Function(DropZoneEvent$Over event) over,
required T Function(DropZoneEvent$Leave event) leave,
required T Function(DropZoneEvent$Drop event) drop,
});
}
/// {@macro drop_zone_event}
final class DropZoneEvent$Enter extends DropZoneEvent {
/// {@macro drop_zone_event}
const DropZoneEvent$Enter({required super.dx, required super.dy});
@override
T map<T>({
required T Function(DropZoneEvent$Enter event) enter,
required T Function(DropZoneEvent$Over event) over,
required T Function(DropZoneEvent$Leave event) leave,
required T Function(DropZoneEvent$Drop event) drop,
}) => enter(this);
@override
String toString() => 'Enter';
}
/// {@macro drop_zone_event}
final class DropZoneEvent$Over extends DropZoneEvent {
/// {@macro drop_zone_event}
const DropZoneEvent$Over({required super.dx, required super.dy});
@override
T map<T>({
required T Function(DropZoneEvent$Enter event) enter,
required T Function(DropZoneEvent$Over event) over,
required T Function(DropZoneEvent$Leave event) leave,
required T Function(DropZoneEvent$Drop event) drop,
}) => over(this);
@override
String toString() => 'Over';
}
/// {@macro drop_zone_event}
final class DropZoneEvent$Leave extends DropZoneEvent {
/// {@macro drop_zone_event}
const DropZoneEvent$Leave({required super.dx, required super.dy});
@override
T map<T>({
required T Function(DropZoneEvent$Enter event) enter,
required T Function(DropZoneEvent$Over event) over,
required T Function(DropZoneEvent$Leave event) leave,
required T Function(DropZoneEvent$Drop event) drop,
}) => leave(this);
@override
String toString() => 'Leave';
}
/// {@macro drop_zone_event}
final class DropZoneEvent$Drop extends DropZoneEvent {
/// {@macro drop_zone_event}
const DropZoneEvent$Drop({required super.dx, required super.dy, required this.items});
/// Map of file names to their byte content.
final Map<String, Uint8List> items;
@override
T map<T>({
required T Function(DropZoneEvent$Enter event) enter,
required T Function(DropZoneEvent$Over event) over,
required T Function(DropZoneEvent$Leave event) leave,
required T Function(DropZoneEvent$Drop event) drop,
}) => drop(this);
@override
String toString() => 'Drop';
}
/// DropZone stream
/// Use it as a [Stream] of [DropZoneEvent]s.
/// e.g. `DropZone().listen((event) { ... })`
///
/// Good idea to show some UI indication when drag-and-drop is active.
/// Display an overlay when [DropZoneEvent$Enter] is received,
/// keep it while [DropZoneEvent$Over] events are received,
/// and hide it when [DropZoneEvent$Leave] or [DropZoneEvent$Drop] is received.
extension type DropZone._(Stream<DropZoneEvent> _source) implements Stream<DropZoneEvent> {
/// Creates a [DropZone] stream.
factory DropZone({
// Max number of files to accept (15 default)
int limit = 15,
// Max file size in bytes (20 MB default)
int size = 20 * 1024 * 1024,
// Accepted file extensions
Set<String> extensions = const <String>{'png', 'jpg', 'jpeg', 'gif', 'bmp', 'txt', 'pdf'},
}) => DropZone._($dropZoneStream(limit: limit, size: size, extensions: extensions));
}
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/widgets.dart';
/// A wrapper around [OverlayEntry] to provide additional functionality.
/// Such as removing the entry if it is mounted.
extension type DropZoneOverlayEntry._(OverlayEntry entry) implements OverlayEntry {
/// Remove the entry if it is mounted.
void maybePop() {
if (entry.mounted) {
entry.remove();
}
}
}
/// {@template drop_zone_overlay}
/// DropZoneOverlay widget with animated visual effects.
/// {@endtemplate}
class DropZoneOverlay extends StatefulWidget {
/// {@macro drop_zone_overlay}
const DropZoneOverlay({
super.key, // ignore: unused_element
});
/// Create an OverlayEntry with DropZoneOverlay as its child.
static DropZoneOverlayEntry entry() =>
DropZoneOverlayEntry._(OverlayEntry(builder: (context) => const Positioned.fill(child: DropZoneOverlay())));
/// Show the DropZoneOverlay in the given context.
/// Returns the OverlayEntry that was inserted.
static DropZoneOverlayEntry show(BuildContext context) {
final entry = DropZoneOverlay.entry();
if (!context.mounted) return entry;
Overlay.maybeOf(context)?.insert(entry);
return entry;
}
@override
State<DropZoneOverlay> createState() => _DropZoneOverlayState();
}
class _DropZoneOverlayState extends State<DropZoneOverlay> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: const Duration(milliseconds: 2000), vsync: this)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
IgnorePointer(ignoring: true, child: CustomPaint(painter: DropZoneOverlayPainter(animation: _controller)));
}
/// {@template drop_zone_overlay_painter}
/// Custom painter for the drop zone overlay with animated effects.
/// {@endtemplate}
class DropZoneOverlayPainter extends CustomPainter {
/// {@macro drop_zone_overlay_painter}
const DropZoneOverlayPainter({required ValueListenable<double> animation})
: _animation = animation,
super(repaint: animation);
final ValueListenable<double> _animation;
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.shortestSide * 0.35;
// Calculate animation values from the listenable (AnimationController)
final animationValue = _animation.value;
// Apply easing curve for smooth animation
final t = Curves.easeInOut.transform(animationValue);
// Calculate animated properties
final scale = 0.98 + (1.0 - 0.98) * t;
final pulse = t;
// Use fixed radius for text positioning to prevent shifting
final animatedRadius = radius * scale;
// Background overlay with subtle gradient effect
final backgroundPaint =
Paint()
..shader = const RadialGradient(
center: Alignment.center,
radius: 1.5,
colors: [Color(0x50000000), Color(0x70000000)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height))
..style = PaintingStyle.fill
..blendMode = BlendMode.srcOver;
canvas.drawRect(Offset.zero & size, backgroundPaint);
// Main drop zone rounded rectangle
final rrect = RRect.fromRectAndRadius(
Rect.fromCenter(center: center, width: animatedRadius * 2, height: animatedRadius * 2),
Radius.circular(animatedRadius * 0.12),
);
// Inner fill with gradient
final fillPaint =
Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0x30FFFFFF), Color(0x10FFFFFF)],
).createShader(rrect.outerRect)
..style = PaintingStyle.fill
..isAntiAlias = true;
canvas.drawRRect(rrect, fillPaint);
// Animated pulsing glow effect
final glowPaint =
Paint()
..color = Color.lerp(const Color(0x20FFFFFF), const Color(0x40FFFFFF), pulse)!
..style = PaintingStyle.stroke
..strokeWidth = 8.0
..maskFilter = MaskFilter.blur(BlurStyle.normal, 12 + pulse * 8)
..isAntiAlias = true;
canvas.drawRRect(rrect, glowPaint);
// Main border with dashed effect
final borderPaint =
Paint()
..color = const Color(0xFFFFFFFF)
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
// Draw dashed border
_drawDashedRRect(canvas, rrect, borderPaint, dashLength: 20, gapLength: 10);
// Upload icon above text - use fixed radius for positioning
_drawUploadIcon(canvas, center, radius, pulse);
// Main text
final mainText = TextPainter(
maxLines: 1,
text: TextSpan(
text: 'Drop files here',
style: TextStyle(
color: const Color(0xFFFFFFFF),
fontSize: switch (size.width) {
>= 800 => 36.0,
>= 600 => 28.0,
_ => 20.0,
},
shadows: const [
Shadow(color: Color(0x80000000), offset: Offset(0, 2), blurRadius: 8),
Shadow(color: Color(0x40000000), offset: Offset(0, 4), blurRadius: 16),
],
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
textDirection: TextDirection.ltr,
)..layout();
// Subtitle text
final subtitleText = TextPainter(
maxLines: 1,
text: TextSpan(
text: 'to attach to your message',
style: TextStyle(
color: const Color(0xE0FFFFFF),
fontSize: switch (size.width) {
>= 800 => 18.0,
>= 600 => 14.0,
_ => 12.0,
},
shadows: const [Shadow(color: Color(0x80000000), offset: Offset(0, 1), blurRadius: 4)],
fontWeight: FontWeight.w400,
letterSpacing: 0.3,
),
),
textDirection: TextDirection.ltr,
)..layout();
// Draw texts centered with proper spacing - use fixed radius to prevent shifting
final textY = center.dy + radius * 0.1;
mainText.paint(canvas, Offset((size.width - mainText.width) / 2, textY));
subtitleText.paint(canvas, Offset((size.width - subtitleText.width) / 2, textY + mainText.height + 8));
}
/// Draw dashed rounded rectangle border.
void _drawDashedRRect(
Canvas canvas,
RRect rrect,
Paint paint, {
required double dashLength,
required double gapLength,
}) {
final path = Path()..addRRect(rrect);
final metrics = path.computeMetrics();
for (final metric in metrics) {
double distance = 0.0;
while (distance < metric.length) {
final nextDistance = distance + dashLength;
final dashPath = metric.extractPath(distance, nextDistance.clamp(0.0, metric.length));
canvas.drawPath(dashPath, paint);
distance = nextDistance + gapLength;
}
}
}
/// Draw upload icon above the text.
void _drawUploadIcon(Canvas canvas, Offset center, double radius, double pulse) {
final iconSize = radius * 0.25;
final iconY = center.dy - radius * 0.3;
final paint =
Paint()
..color = Color.lerp(const Color(0xFFFFFFFF), const Color(0xFFE0E0E0), pulse * 0.3)!
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
final fillPaint =
Paint()
..color = Color.lerp(const Color(0x20FFFFFF), const Color(0x40FFFFFF), pulse)!
..style = PaintingStyle.fill
..isAntiAlias = true;
// Cloud shape
final cloudPath = Path();
final cloudCenter = Offset(center.dx, iconY);
// Draw simplified cloud with arc
cloudPath.addOval(
Rect.fromCenter(center: cloudCenter.translate(-iconSize * 0.3, 0), width: iconSize * 0.5, height: iconSize * 0.4),
);
cloudPath.addOval(
Rect.fromCenter(
center: cloudCenter.translate(0, -iconSize * 0.15),
width: iconSize * 0.6,
height: iconSize * 0.5,
),
);
cloudPath.addOval(
Rect.fromCenter(center: cloudCenter.translate(iconSize * 0.3, 0), width: iconSize * 0.5, height: iconSize * 0.4),
);
canvas.drawPath(cloudPath, fillPaint);
canvas.drawPath(cloudPath, paint);
// Upload arrow
final arrowPath =
Path()
..moveTo(center.dx, iconY + iconSize * 0.35)
..lineTo(center.dx, iconY + iconSize * 0.05)
..moveTo(center.dx - iconSize * 0.15, iconY + iconSize * 0.2)
..lineTo(center.dx, iconY + iconSize * 0.05)
..lineTo(center.dx + iconSize * 0.15, iconY + iconSize * 0.2);
canvas.drawPath(arrowPath, paint);
}
@override
bool shouldRepaint(DropZoneOverlayPainter oldDelegate) => false;
@override
bool shouldRebuildSemantics(DropZoneOverlayPainter oldDelegate) => false;
}
import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data' show Uint8List;
import 'package:path/path.dart' as p;
import 'package:web/web.dart' as web;
import 'dart:typed_data' show Uint8List;
/// Returns a stream of drag-and-drop events occurring on the web page.
Stream<DropZoneEvent> $dropZoneStream({
// Max number of files to accept
required int limit,
// Max file size in bytes
required int size,
// Accepted file extensions
required Set<String> extensions,
}) {
var closed = false; // To ensure we only close once
var tearDown = () {}; // Cleanup function
// Print message to console if in debug mode
void print(String message) {
assert(() {
web.console.log(message.toJS);
return true;
}(), 'No-op in release mode');
}
// Register a cleanup function to be called on stream cancellation
void onCleanUp(void Function() fn) {
final $tearDown = tearDown;
tearDown = () {
try {
fn();
} on Object {
print('Error during tearDown');
} finally {
$tearDown();
}
};
}
// Create the stream controller with onCancel to clean up
final controller =
StreamController<DropZoneEvent>()
..onCancel = () {
tearDown();
};
onCleanUp(() {
if (!controller.isClosed) controller.close().ignore();
tearDown = () {};
});
int dragDepth = 0; // To track nested drag events
// Add event to the stream
void add(DropZoneEvent event) {
if (closed || controller.isClosed) return;
controller.add(event);
}
// Add error to the stream
void addError(Object error, [StackTrace? stackTrace]) {
if (closed || controller.isClosed) return;
controller.addError(error, stackTrace);
}
// Check if the file is accepted based on size and extension
bool isAcceptedFile(web.File file) {
print('Checking file: ${file.name}, size: ${file.size}');
// Check file size
if (file.size case int value when value <= 0 || value > size) return false;
// Check file extension
final ext = p.extension(file.name).trim().toLowerCase();
if (ext.isEmpty) return false;
if (!extensions.contains(ext.substring(1))) return false;
print('Accepted file: ${file.name}, size: ${file.size}');
return true;
}
// Convert a web.File to bytes (Uint8List)
Future<Uint8List> fileBytes(web.File file) async {
final buffer = await file.arrayBuffer().toDart;
return Uint8List.view(buffer.toDart);
}
// Called when a drag event enters the page
final onDragEnter =
(web.Event e) {
if (!e.isA<web.DragEvent>()) return;
final de = e as web.DragEvent;
if (de.dataTransfer == null) return;
de.preventDefault();
de.stopPropagation();
dragDepth++;
add(DropZoneEvent$Enter(dx: de.clientX, dy: de.clientY));
print('Drag enter, depth: $dragDepth');
}.toJS;
// Called when a drag event is over the page
// Can be called multiple times per drag
final onDragOver =
(web.Event e) {
if (!e.isA<web.DragEvent>()) return;
final de = e as web.DragEvent;
if (de.dataTransfer == null) return;
de.preventDefault();
de.stopPropagation();
de.dataTransfer?.dropEffect = 'copy';
add(DropZoneEvent$Over(dx: de.clientX, dy: de.clientY));
print('Drag over, depth: $dragDepth');
}.toJS;
// Called when a drag event leaves the page
final onDragLeave =
(web.Event e) {
if (!e.isA<web.DragEvent>()) return;
final de = e as web.DragEvent;
de.preventDefault();
de.stopPropagation();
dragDepth = (dragDepth - 1).clamp(0, 1 << 20);
if (dragDepth == 0) add(DropZoneEvent$Leave(dx: de.clientX, dy: de.clientY));
print('Drag leave, depth: $dragDepth');
}.toJS;
// Called when a drop event occurs on the page
// Files can be accessed via dataTransfer.files or dataTransfer.items
// Can have multiple items, some of which may not be files (e.g. text)
// We only accept files that match the criteria (size, extension)
final onDrop =
(web.Event e) {
if (!e.isA<web.DragEvent>()) return;
final de = e as web.DragEvent;
de.preventDefault();
de.stopPropagation();
dragDepth = 0;
final web.DragEvent(clientX: dx, clientY: dy, dataTransfer: dt) = de;
if (dt == null) {
add(DropZoneEvent$Over(dx: de.clientX, dy: de.clientY));
return;
}
final web.DataTransfer(:items, :files) = dt;
final result = <web.File>[];
if (items.length > 0) {
print('Drop items: ${items.length}');
for (var i = 0; i < items.length; i++) {
if (result.length >= limit) break;
try {
final item = items[i];
if (item.kind == 'file') {
final f = item.getAsFile();
if (f == null || !isAcceptedFile(f)) {
print('Ignoring invalid file item: ${item.kind}');
continue;
}
result.add(f);
} else {
// Handle non-file items if needed
print('Ignoring non-file item: ${item.kind}');
}
} on Object {
print('Error processing dropped item #$i');
}
}
if (result.isEmpty) print('No valid items dropped');
} else if (files.length > 0) {
//print('Drop files: ${files.length}'.toJS);
for (var i = 0; i < files.length; i++) {
if (result.length >= limit) break;
try {
final f = files.item(i);
if (f == null || !isAcceptedFile(f)) {
print('Ignoring invalid file: ${f?.name}');
continue;
}
result.add(f);
} on Object {
print('Error processing dropped file #$i');
}
}
if (result.isEmpty) print('No valid files dropped');
} else {
//print('No valid items or files dropped'.toJS);
}
// If no valid files, send empty drop event
if (result.isEmpty) {
add(DropZoneEvent$Drop(dx: de.clientX, dy: de.clientY, items: const {}));
return;
}
Future.wait<MapEntry<String, Uint8List>>([
for (final file in result) fileBytes(file).then((b) => MapEntry(file.name, b)),
]).then(Map.fromEntries).then((items) => DropZoneEvent$Drop(dx: dx, dy: dy, items: items)).then(add).ignore();
}.toJS;
// Listen for events
controller.onListen = () async {
final root = web.document.documentElement ?? web.document.body ?? web.document;
// Add root listeners
try {
final listeners = <String, JSExportedDartFunction>{
'dragenter': onDragEnter,
'dragover': onDragOver,
'dragleave': onDragLeave,
'drop': onDrop,
};
for (final MapEntry(:key, :value) in listeners.entries) {
try {
// Add listener
root.addEventListener(key, value);
// Clean up
onCleanUp(() {
root.removeEventListener(key, value);
});
} on Object {
print('Error adding event listener $key');
}
}
} on Object catch (e, s) {
addError(e, s);
tearDown();
return;
}
// Add window listeners
try {
final listeners = <String, JSExportedDartFunction>{'dragover': onDragOver, 'drop': onDrop};
for (final MapEntry(:key, :value) in listeners.entries) {
try {
// Add listener
web.window.addEventListener(key, value);
// Clean up
onCleanUp(() {
web.window.removeEventListener(key, value);
});
} on Object {
print('Error adding window event listener $key');
}
}
} on Object catch (e, s) {
addError(e, s);
tearDown();
return;
}
// Ensure we only close once
onCleanUp(() {
if (closed) return;
closed = true;
});
// Initial log
print('Drag and drop initialized');
};
return controller.stream;
}
import 'drop_zone.dart' show DropZoneEvent;
/// Returns an empty stream as drag-and-drop is not supported on this platform.
Stream<DropZoneEvent> $dropZoneStream({
// Max number of files to accept
required int limit,
// Max file size in bytes
required int size,
// Accepted file extensions
required Set<String> extensions,
}) => const Stream<DropZoneEvent>.empty();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment