selectable_area 1.0.1
selectable_area: ^1.0.1 copied to clipboard
A powerful and flexible Flutter widget that allows users to select and capture a rectangular area over any child widget.
example/lib/main.dart
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:selectable_area/selectable_area.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'SelectableArea Example',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true),
home: const ExampleScreen(),
);
}
}
// The main screen that hosts the examples.
class ExampleScreen extends StatefulWidget {
const ExampleScreen({super.key});
@override
State<ExampleScreen> createState() => _ExampleScreenState();
}
class _ExampleScreenState extends State<ExampleScreen> {
// --- STATE VARIABLES ---
// Controls whether the selection functionality is enabled on the SelectableArea widgets.
bool _isSelectionEnabled = true;
// Determines the output format (base64 string or raw bytes) for the captured selection.
SelectionOutputType _outputType = SelectionOutputType.base64;
/// This callback is triggered by the SelectableArea widget when a selection is complete.
/// It handles both `base64` (String) and `bytes` (Uint8List) output types.
void _handleSelection(dynamic selectionResult) {
Uint8List imageBytes;
String resultMeta;
// Determine the type of the result and decode if necessary.
if (selectionResult is String) {
imageBytes = base64Decode(selectionResult);
resultMeta = "Received base64 string of length: ${selectionResult.length}";
} else if (selectionResult is Uint8List) {
imageBytes = selectionResult;
resultMeta = "Received Uint8List of length: ${selectionResult.length}";
} else {
// Exit if the result is not a recognized type.
return;
}
debugPrint(resultMeta);
// Show a dialog with the captured image.
showDialog(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.0)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Selection Captured!', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
// Display the captured image from its byte data.
ClipRRect(borderRadius: BorderRadius.circular(12.0), child: Image.memory(imageBytes)),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer.withAlpha(100),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Output Type: ${_outputType.name}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSecondaryContainer),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.check_circle_outline),
label: const Text('OK'),
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
// DefaultTabController coordinates the TabBar and TabBarView.
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(title: const Text('SelectableArea',), centerTitle: true),
body: Column(
children: [
// Tabs to switch between the different examples.
const TabBar(
tabs: [
Tab(text: 'Image Example', icon: Icon(Icons.image_outlined)),
Tab(text: 'Widget Example', icon: Icon(Icons.widgets_outlined)),
],
),
// The global controls for changing the output type.
_buildGlobalControls(),
Expanded(
child: TabBarView(
// Disable swiping between tabs to prevent gesture conflicts.
physics: NeverScrollableScrollPhysics(),
children: [_buildImageExample(), _buildWidgetExample()],
),
),
],
),
),
);
}
/// Builds the toggle buttons for choosing the output format (Base64 or Bytes).
Widget _buildGlobalControls() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Center(
child: ToggleButtons(
isSelected: [_outputType == SelectionOutputType.base64, _outputType == SelectionOutputType.bytes],
onPressed: (index) {
setState(() {
_outputType = index == 0 ? SelectionOutputType.base64 : SelectionOutputType.bytes;
});
},
borderRadius: BorderRadius.circular(8.0),
selectedColor: Colors.white,
color: Theme.of(context).colorScheme.primary,
fillColor: Theme.of(context).colorScheme.primary,
splashColor: Theme.of(context).colorScheme.primary.withAlpha(31),
hoverColor: Theme.of(context).colorScheme.primary.withAlpha(10),
constraints: const BoxConstraints(minHeight: 40.0, minWidth: 170.0),
children: const [
Row(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.data_object_outlined), SizedBox(width: 8), Text('Base64 Output')]),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.memory_outlined), SizedBox(width: 8), Text('Bytes Output')]),
],
),
),
);
}
/// Builds a simple card with a ListTile and a Switch to enable or disable selection.
Widget _buildEnableSelectionCard() {
return Card(
child: ListTile(
title: const Text('Enable Selection'),
trailing: Transform.scale(
scale: 0.8, // Makes the switch smaller
child: Switch(value: _isSelectionEnabled, onChanged: (value) => setState(() => _isSelectionEnabled = value)),
),
),
);
}
/// Builds the first example, showing how to select an area over a static Image widget.
Widget _buildImageExample() {
return Column(
children: [
const Text('This example shows how to select an area over an image.', textAlign: TextAlign.center),
const SizedBox(height: 16),
_buildEnableSelectionCard(),
const SizedBox(height: 16),
Card(
elevation: 2,
clipBehavior: Clip.antiAlias,
child: SelectableArea(
isEnabled: _isSelectionEnabled,
outputType: _outputType,
onSelectionEnd: _handleSelection,
child: Image.network(
'https://e7.pngegg.com/pngimages/441/311/png-clipart-identity-document-identification-security-hologram-badge-template-identity-card-company-text.png',
// Example receipt image
loadingBuilder: (context, child, progress) {
return progress == null ? child : const Center(heightFactor: 4, child: CircularProgressIndicator());
},
),
),
),
],
);
}
/// Builds the second example, showing how to select an area over a scrollable list of widgets.
Widget _buildWidgetExample() {
return SingleChildScrollView(
/* --- IMPORTANT: SCROLLING FIX ---
This is the core of the solution to the gesture conflict.
THE PROBLEM: A `SingleChildScrollView` and the `GestureDetector` inside
`SelectableArea` both want to respond to a vertical drag gesture.
This creates an ambiguity where the app might scroll while the user is
trying to draw a selection box, leading to incorrect coordinates.
THE SOLUTION: We dynamically change the scroll physics.
- When the user is selecting (`_isSelecting` is true), we use
`NeverScrollableScrollPhysics()` to completely DISABLE scrolling.
- When the user is not selecting, we use normal `ClampingScrollPhysics()`.
The `_isSelecting` flag is controlled by the `onDragStarted` and
`onDragEnded` callbacks provided by the `SelectableArea` widget.*/
physics: _isSelectionEnabled ? const NeverScrollableScrollPhysics() : const ClampingScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text('This example shows how to select an area over any widget, like this list of contacts.', textAlign: TextAlign.center),
const SizedBox(height: 16),
_buildEnableSelectionCard(),
const SizedBox(height: 16),
Card(
elevation: 2,
clipBehavior: Clip.antiAlias,
child: SelectableArea(
isEnabled: _isSelectionEnabled,
outputType: _outputType,
onSelectionEnd: _handleSelection,
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: const [
ListTile(leading: Icon(Icons.person), title: Text('Brenda'), subtitle: Text('(555) 123-4567')),
Divider(height: 1),
ListTile(leading: Icon(Icons.person_outline), title: Text('David'), subtitle: Text('(555) 987-6543')),
Divider(height: 1),
ListTile(leading: Icon(Icons.person_pin), title: Text('Charles'), subtitle: Text('(555) 555-1212')),
Divider(height: 1),
ListTile(leading: Icon(Icons.person), title: Text('Emily'), subtitle: Text('(555) 345-6789')),
],
),
),
),
],
),
);
}
}