selectable_area 1.0.3
selectable_area: ^1.0.3 copied to clipboard
A powerful and flexible Flutter widget that allows users to select and capture a rectangular area over any child widget.
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'),
),
],
),
),
),
],
),
);
}
}