custom_interactive_viewer 0.1.0
custom_interactive_viewer: ^0.1.0 copied to clipboard
A highly customizable alternative for Flutter's InteractiveViewer with advanced keyboard navigation, gesture controls, rotation support, and programmatic transformations.
import 'package:flutter/material.dart';
import 'package:custom_interactive_viewer/custom_interactive_viewer.dart';
void main() => runApp(const MaterialApp(home: CameraPlayground()));
class CameraPlayground extends StatefulWidget {
const CameraPlayground({super.key});
@override
State<CameraPlayground> createState() => _CameraPlaygroundState();
}
class _CameraPlaygroundState extends State<CameraPlayground> {
final camera = CustomInteractiveViewerController(
initialOffset: const Offset(350, 240),
);
bool bounded = false, rtl = false, rotate = false;
ViewerViewport? viewport;
static const world = Rect.fromLTWH(-800, -600, 1600, 1200);
@override
void dispose() {
camera.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Directionality(
textDirection: rtl ? TextDirection.rtl : TextDirection.ltr,
child: Scaffold(
appBar: AppBar(title: const Text('Camera playground')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
FilterChip(
label: const Text('Content bounds'),
selected: bounded,
onSelected: (v) => setState(() => bounded = v),
),
FilterChip(
label: const Text('Rotation'),
selected: rotate,
onSelected: (v) => setState(() => rotate = v),
),
FilterChip(
label: const Text('RTL text'),
selected: rtl,
onSelected: (v) => setState(() => rtl = v),
),
OutlinedButton(
onPressed: () {
if (viewport != null) {
camera.zoomToRegion(world, viewport!.size);
}
},
child: const Text('Fit world'),
),
OutlinedButton(
onPressed: () => camera.reset(),
child: const Text('Reset camera'),
),
OutlinedButton(
onPressed: () => camera.centerOnRect(
const Rect.fromLTWH(-450, -200, 180, 90),
),
child: const Text('Reveal negative region'),
),
],
),
),
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Pan in any direction. Pinch or Ctrl-scroll to zoom. Negative coordinates are ordinary world positions. RTL changes text, not camera geometry.',
),
),
Expanded(
child: CustomInteractiveViewer.world(
controller: camera,
zoomConfig: const ZoomConfig(minScale: .1, maxScale: 8),
interactionConfig: InteractionConfig(
enableRotation: rotate,
boundsBehavior: bounded
? const WorldBoundsBehavior(world, margin: 100)
: null,
),
viewportBuilder: (context, view) {
viewport = view;
final point = view.worldToViewport(const Offset(-450, -200));
return Stack(
children: [
Positioned.fill(
child: CustomPaint(painter: WorldPainter(view)),
),
Positioned(
left: point.dx,
top: point.dy,
child: Transform.scale(
alignment: Alignment.topLeft,
scale: view.camera.scale,
child: Card(
child: InkWell(
onTap: () =>
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Negative-coordinate region selected',
),
),
),
child: const SizedBox(
width: 180,
height: 90,
child: Center(child: Text('World: −450, −200')),
),
),
),
),
),
Positioned(
left: 16,
bottom: 16,
child: Text(
'Zoom: ${view.camera.scale.toStringAsFixed(2)} · Camera: ${view.camera.offset}',
),
),
],
);
},
),
),
],
),
),
);
}
class WorldPainter extends CustomPainter {
final ViewerViewport viewport;
WorldPainter(this.viewport);
@override
void paint(Canvas canvas, Size size) {
canvas.drawColor(const Color(0xfff5f7f8), BlendMode.src);
canvas.save();
canvas.transform(viewport.worldToViewportMatrix.storage);
final bounds = viewport.visibleWorldBounds;
final step = viewport.camera.scale < .3 ? 200.0 : 50.0;
final grid = Paint()
..color = const Color(0xffd8dfe3)
..strokeWidth = 1 / viewport.camera.scale;
for (
double x = (bounds.left / step).floor() * step;
x <= bounds.right;
x += step
) {
canvas.drawLine(Offset(x, bounds.top), Offset(x, bounds.bottom), grid);
}
for (
double y = (bounds.top / step).floor() * step;
y <= bounds.bottom;
y += step
) {
canvas.drawLine(Offset(bounds.left, y), Offset(bounds.right, y), grid);
}
final axes = Paint()
..color = Colors.teal
..strokeWidth = 2 / viewport.camera.scale;
canvas.drawLine(Offset(0, bounds.top), Offset(0, bounds.bottom), axes);
canvas.drawLine(Offset(bounds.left, 0), Offset(bounds.right, 0), axes);
canvas.restore();
}
@override
bool shouldRepaint(WorldPainter old) => old.viewport != viewport;
}