interactive_viewer_2 0.1.0 copy "interactive_viewer_2: ^0.1.0" to clipboard
interactive_viewer_2: ^0.1.0 copied to clipboard

A Flutter InteractiveViewer replacement with support for scrollbars, double-tap zoom and overall improved zoom handling

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:interactive_viewer_2/interactive_viewer_2.dart';
import 'presentations/grid_presentation.dart' as grid_demo;
import 'presentations/logo_presentation.dart' as logo_demo;
import 'presentations/image_presentation.dart' as image_demo;
import 'package:flutter/services.dart';
import 'package:syntax_highlight/syntax_highlight.dart' as sh;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'InteractiveViewer2 Showcase',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      home: const ViewerDemoPage(),
    );
  }
}

enum PresentationMode { grid, logo, image }

enum ViewerImplementation { interactiveViewer2, interactiveViewer }

class ViewerDemoPage extends StatefulWidget {
  const ViewerDemoPage({super.key});

  @override
  State<ViewerDemoPage> createState() => _ViewerDemoPageState();
}

class _ViewerDemoPageState extends State<ViewerDemoPage> {
  final TransformationController _tc = TransformationController();

  bool _showScrollbars = true;
  bool _allowNonCovering = true;
  bool _panEnabled = true;
  bool _scaleEnabled = true;
  bool _doubleTapToZoom = true;
  bool _noMouseDragScroll = true;
  bool _constrained = false;

  double _minScale = 0.2;
  double _maxScale = 3.0;
  double _scaleFactor = 200.0;

  PanAxis _panAxis = PanAxis.free;
  HorizontalNonCoveringZoomAlign _hAlign =
      HorizontalNonCoveringZoomAlign.middle;
  VerticalNonCoveringZoomAlign _vAlign = VerticalNonCoveringZoomAlign.middle;
  DoubleTapZoomOutBehaviour _doubleTapBehaviour =
      DoubleTapZoomOutBehaviour.zoomOutToMinScale;

  PresentationMode _mode = PresentationMode.grid; // default
  ViewerImplementation _viewer = ViewerImplementation.interactiveViewer2;

  @override
  void dispose() {
    _tc.dispose();
    super.dispose();
  }

  void _reset() {
    setState(() {
      _tc.value = Matrix4.identity();
    });
  }

  void _showCodeDialog() {
    final code = _buildViewerCodeSnippet(_mode);

    showDialog(
      context: context,
      barrierDismissible: true,
      builder: (ctx) {
        final size = MediaQuery.of(ctx).size;
        final dialogWidth = (size.width * 0.9).clamp(600.0, 1200.0);
        final dialogHeight = (size.height * 0.85).clamp(400.0, 900.0);
        final viewerTitle = _viewer == ViewerImplementation.interactiveViewer2
            ? 'InteractiveViewer2'
            : 'InteractiveViewer';
        return Dialog(
          backgroundColor: Colors.transparent,
          insetPadding: const EdgeInsets.symmetric(
            horizontal: 24,
            vertical: 24,
          ),
          child: Material(
            color: DialogTheme.of(ctx).backgroundColor,
            elevation: 6,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(16),
            ),
            clipBehavior: Clip.antiAlias,
            child: SizedBox(
              width: dialogWidth,
              height: dialogHeight,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Container(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 16,
                      vertical: 12,
                    ),
                    color: Theme.of(ctx).colorScheme.surfaceContainerHighest
                        .withValues(alpha: 0.5),
                    child: Row(
                      children: [
                        const Icon(Icons.code),
                        const SizedBox(width: 8),
                        Expanded(
                          child: Text(
                            '$viewerTitle code (based on current settings)',
                            style: const TextStyle(fontWeight: FontWeight.w600),
                          ),
                        ),
                        TextButton.icon(
                          onPressed: () async {
                            await Clipboard.setData(ClipboardData(text: code));
                            if (mounted) {
                              ScaffoldMessenger.of(context).showSnackBar(
                                const SnackBar(
                                  content: Text('Code copied to clipboard'),
                                ),
                              );
                            }
                          },
                          icon: const Icon(Icons.copy),
                          label: const Text('Copy'),
                        ),
                        const SizedBox(width: 8),
                      ],
                    ),
                  ),
                  const Divider(height: 1),
                  Expanded(
                    child: FutureBuilder<List<sh.HighlighterTheme>>(
                      future: () async {
                        await sh.Highlighter.initialize(['dart']);
                        final lightTheme = sh.HighlighterTheme.loadLightTheme();
                        final darkTheme = sh.HighlighterTheme.loadDarkTheme();
                        return Future.wait([lightTheme, darkTheme]);
                      }(),
                      builder: (context, snapshot) {
                        if (snapshot.connectionState != ConnectionState.done) {
                          return const Center(
                            child: CircularProgressIndicator(),
                          );
                        }

                        final isDark =
                            Theme.of(context).brightness == Brightness.dark;
                        final baseStyle = TextStyle(
                          fontFamily: 'monospace',
                          fontSize: 13.5,
                          height: 1.5,
                          color: isDark ? Colors.white : Colors.black,
                        );

                        final highlighter = sh.Highlighter(
                          language: 'dart',
                          theme: isDark ? snapshot.data![1] : snapshot.data![0],
                        );
                        final TextSpan highlighted = highlighter.highlight(
                          code,
                        );

                        return Scrollbar(
                          thumbVisibility: true,
                          child: SingleChildScrollView(
                            padding: const EdgeInsets.all(16),
                            child: DefaultTextStyle(
                              style: baseStyle,
                              child: SelectableText.rich(
                                highlighted,
                                textScaler: const TextScaler.linear(1.0),
                              ),
                            ),
                          ),
                        );
                      },
                    ),
                  ),
                  const Divider(height: 1),
                  Align(
                    alignment: Alignment.centerRight,
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: TextButton(
                        onPressed: () => Navigator.of(ctx).maybePop(),
                        child: const Text('Close'),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }

  String _buildViewerCodeSnippet(PresentationMode mode) {
    String enumLiteral(Object e) =>
        e.toString().split('.').last; // PanAxis.free -> free

    String viewerChildSnippet;
    switch (mode) {
      case PresentationMode.grid:
        viewerChildSnippet =
            'SizedBox(\n      width: 3000,\n      height: 2000,\n      child: CustomPaint(\n        painter: GridPainter(), // your painter\n      ),\n  )';
        break;
      case PresentationMode.logo:
        viewerChildSnippet = 'const FlutterLogo(size: 300)';
        break;
      case PresentationMode.image:
        viewerChildSnippet = "Image.asset('assets/owl-2.jpg')";
        break;
    }

    final buf = StringBuffer();
    buf.writeln('// Paste inside a build() method or a widget tree');
    buf.writeln("// Requires: import 'package:flutter/material.dart';");

    if (_viewer == ViewerImplementation.interactiveViewer2) {
      buf.writeln(
        "//          import 'package:interactive_viewer_2/interactive_viewer_2.dart';",
      );
      buf.writeln('');
      buf.writeln('InteractiveViewer2(');
      buf.writeln('  allowNonCoveringScreenZoom: $_allowNonCovering,');
      buf.writeln('  panAxis: PanAxis.${enumLiteral(_panAxis)},');
      buf.writeln('  panEnabled: $_panEnabled,');
      buf.writeln('  scaleEnabled: $_scaleEnabled,');
      buf.writeln('  showScrollbars: $_showScrollbars,');
      buf.writeln('  noMouseDragScroll: $_noMouseDragScroll,');
      buf.writeln('  scaleFactor: ${_scaleFactor.toStringAsFixed(1)},');
      buf.writeln('  minScale: ${_minScale.toStringAsFixed(2)},');
      buf.writeln('  maxScale: ${_maxScale.toStringAsFixed(2)},');
      buf.writeln('  doubleTapToZoom: $_doubleTapToZoom,');
      buf.writeln(
        '  nonCoveringZoomAlignmentHorizontal: HorizontalNonCoveringZoomAlign.${enumLiteral(_hAlign)},',
      );
      buf.writeln(
        '  nonCoveringZoomAlignmentVertical: VerticalNonCoveringZoomAlign.${enumLiteral(_vAlign)},',
      );
      buf.writeln(
        '  doubleTapZoomOutBehaviour: DoubleTapZoomOutBehaviour.${enumLiteral(_doubleTapBehaviour)},',
      );
      buf.writeln('  clipBehavior: Clip.hardEdge,');
      buf.writeln('  constrained: $_constrained,');
      buf.writeln('  child: $viewerChildSnippet,');
      buf.writeln(');');
    } else {
      buf.writeln('');
      buf.writeln('InteractiveViewer(');
      buf.writeln('  panEnabled: $_panEnabled,');
      buf.writeln('  panAxis: PanAxis.${enumLiteral(_panAxis)},');
      buf.writeln('  scaleEnabled: $_scaleEnabled,');
      buf.writeln('  minScale: ${_minScale.toStringAsFixed(2)},');
      buf.writeln('  maxScale: ${_maxScale.toStringAsFixed(2)},');
      buf.writeln('  clipBehavior: Clip.hardEdge,');
      buf.writeln('  constrained: $_constrained,');
      buf.writeln('  child: $viewerChildSnippet,');
      buf.writeln(');');
    }

    if (mode == PresentationMode.grid) {
      buf.writeln('');
      buf.writeln('// Example painter (optional):');
      buf.writeln('class GridPainter extends CustomPainter {');
      buf.writeln('  @override');
      buf.writeln('  void paint(Canvas canvas, Size size) {');
      buf.writeln('    // draw your grid/content here');
      buf.writeln('  }');
      buf.writeln('  @override');
      buf.writeln(
        '  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;',
      );
      buf.writeln('}');
    }

    return buf.toString();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('InteractiveViewer2 Showcase'),
        actions: [
          IconButton(
            tooltip: 'Reset',
            onPressed: _reset,
            icon: const Icon(Icons.restore),
          ),
          IconButton(
            tooltip: 'Show code',
            onPressed: _showCodeDialog,
            icon: const Icon(Icons.code),
          ),
        ],
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final Size viewport = Size(
            constraints.maxWidth - 320,
            constraints.maxHeight,
          );
          final m = _tc.value.storage;
          final double approxScale = m[0];

          return Row(
            children: [
              SizedBox(
                width: 320,
                child: SingleChildScrollView(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Status',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      Text('Scale: ${approxScale.toStringAsFixed(2)}'),
                      Text(
                        'Viewport: ${viewport.width.toStringAsFixed(0)} x ${viewport.height.toStringAsFixed(0)}',
                      ),
                      const Divider(height: 24),

                      Text(
                        'Actions',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      Wrap(
                        spacing: 8,
                        runSpacing: 8,
                        children: [
                          ElevatedButton.icon(
                            onPressed: _reset,
                            icon: const Icon(Icons.restore),
                            label: const Text('Reset'),
                          ),
                          ElevatedButton.icon(
                            onPressed: _showCodeDialog,
                            icon: const Icon(Icons.code),
                            label: const Text('Show code'),
                          ),
                        ],
                      ),

                      const Divider(height: 24),
                      Text(
                        'Viewer',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      DropdownButton<ViewerImplementation>(
                        value: _viewer,
                        isExpanded: true,
                        onChanged: (v) {
                          _tc.value = Matrix4.identity();
                          setState(() => _viewer =
                              v ?? ViewerImplementation.interactiveViewer2);
                        },
                        items: const [
                          DropdownMenuItem(
                            value: ViewerImplementation.interactiveViewer2,
                            child: Text('InteractiveViewer2 (package)'),
                          ),
                          DropdownMenuItem(
                            value: ViewerImplementation.interactiveViewer,
                            child: Text('InteractiveViewer (Flutter)'),
                          ),
                        ],
                      ),

                      const Divider(height: 24),
                      Text(
                        'Presentation',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      DropdownButton<PresentationMode>(
                        value: _mode,
                        isExpanded: true,
                        onChanged: (v) {
                          _tc.value = Matrix4.identity();
                          setState(() => _mode = v ?? PresentationMode.grid);
                        },
                        items: const [
                          DropdownMenuItem(
                            value: PresentationMode.grid,
                            child: Text('Grid'),
                          ),
                          DropdownMenuItem(
                            value: PresentationMode.logo,
                            child: Text('Logo'),
                          ),
                          DropdownMenuItem(
                            value: PresentationMode.image,
                            child: Text('Image'),
                          ),
                        ],
                      ),

                      const Divider(height: 24),
                      Text(
                        'Options',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      _boolTile(
                        title: 'Show scrollbars',
                        value: _showScrollbars,
                        onChanged: (v) => setState(() => _showScrollbars = v),
                      ),
                      _boolTile(
                        title: 'Allow non-covering zoom',
                        value: _allowNonCovering,
                        onChanged: (v) => setState(() => _allowNonCovering = v),
                      ),
                      _boolTile(
                        title: 'Pan enabled',
                        value: _panEnabled,
                        onChanged: (v) => setState(() => _panEnabled = v),
                      ),
                      _boolTile(
                        title: 'Scale enabled',
                        value: _scaleEnabled,
                        onChanged: (v) => setState(() => _scaleEnabled = v),
                      ),
                      _boolTile(
                        title: 'Double-tap to zoom',
                        value: _doubleTapToZoom,
                        onChanged: (v) => setState(() => _doubleTapToZoom = v),
                      ),
                      _boolTile(
                        title: 'Disable mouse drag scroll',
                        subtitle: 'When off, you can drag with the mouse',
                        value: _noMouseDragScroll,
                        onChanged: (v) =>
                            setState(() => _noMouseDragScroll = v),
                      ),
                      _boolTile(
                        title: 'Constrained',
                        subtitle: 'Apply parent constraints to child',
                        value: _constrained,
                        onChanged: (v) => setState(() => _constrained = v),
                      ),

                      const SizedBox(height: 12),
                      Text('Pan axis'),
                      DropdownButton<PanAxis>(
                        value: _panAxis,
                        isExpanded: true,
                        onChanged: (v) =>
                            setState(() => _panAxis = v ?? PanAxis.free),
                        items: const [
                          DropdownMenuItem(
                            value: PanAxis.free,
                            child: Text('free'),
                          ),
                          DropdownMenuItem(
                            value: PanAxis.horizontal,
                            child: Text('horizontal'),
                          ),
                          DropdownMenuItem(
                            value: PanAxis.vertical,
                            child: Text('vertical'),
                          ),
                          DropdownMenuItem(
                            value: PanAxis.aligned,
                            child: Text('aligned'),
                          ),
                        ],
                      ),

                      const SizedBox(height: 12),
                      Text('Double-tap zoom-out'),
                      DropdownButton<DoubleTapZoomOutBehaviour>(
                        value: _doubleTapBehaviour,
                        isExpanded: true,
                        onChanged: (v) => setState(
                          () => _doubleTapBehaviour =
                              v ?? DoubleTapZoomOutBehaviour.zoomOutToMinScale,
                        ),
                        items: const [
                          DropdownMenuItem(
                            value: DoubleTapZoomOutBehaviour.zoomOutToMinScale,
                            child: Text('to min scale (fit all)'),
                          ),
                          DropdownMenuItem(
                            value:
                                DoubleTapZoomOutBehaviour.zoomOutToMatchWidth,
                            child: Text('fit width'),
                          ),
                          DropdownMenuItem(
                            value:
                                DoubleTapZoomOutBehaviour.zoomOutToMatchHeight,
                            child: Text('fit height'),
                          ),
                        ],
                      ),

                      const SizedBox(height: 12),
                      Text('Alignment (when content is smaller than viewport)'),
                      Row(
                        children: [
                          Expanded(
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                const Text('Horizontal'),
                                DropdownButton<HorizontalNonCoveringZoomAlign>(
                                  value: _hAlign,
                                  isExpanded: true,
                                  onChanged: (v) => setState(
                                    () => _hAlign =
                                        v ??
                                        HorizontalNonCoveringZoomAlign.middle,
                                  ),
                                  items: const [
                                    DropdownMenuItem(
                                      value:
                                          HorizontalNonCoveringZoomAlign.left,
                                      child: Text('left'),
                                    ),
                                    DropdownMenuItem(
                                      value:
                                          HorizontalNonCoveringZoomAlign.middle,
                                      child: Text('center'),
                                    ),
                                    DropdownMenuItem(
                                      value:
                                          HorizontalNonCoveringZoomAlign.right,
                                      child: Text('right'),
                                    ),
                                  ],
                                ),
                              ],
                            ),
                          ),
                          const SizedBox(width: 8),
                          Expanded(
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                const Text('Vertical'),
                                DropdownButton<VerticalNonCoveringZoomAlign>(
                                  value: _vAlign,
                                  isExpanded: true,
                                  onChanged: (v) => setState(
                                    () => _vAlign =
                                        v ??
                                        VerticalNonCoveringZoomAlign.middle,
                                  ),
                                  items: const [
                                    DropdownMenuItem(
                                      value: VerticalNonCoveringZoomAlign.top,
                                      child: Text('top'),
                                    ),
                                    DropdownMenuItem(
                                      value:
                                          VerticalNonCoveringZoomAlign.middle,
                                      child: Text('center'),
                                    ),
                                    DropdownMenuItem(
                                      value:
                                          VerticalNonCoveringZoomAlign.bottom,
                                      child: Text('bottom'),
                                    ),
                                  ],
                                ),
                              ],
                            ),
                          ),
                        ],
                      ),

                      const SizedBox(height: 12),
                      Text('Min/Max scale'),
                      Row(
                        children: [
                          Expanded(
                            child: _numberField(
                              label: 'min',
                              value: _minScale,
                              onChanged: (v) {
                                final d = double.tryParse(v) ?? _minScale;
                                setState(() {
                                  _minScale = d.clamp(0.05, 10.0);
                                  if (_maxScale < _minScale) {
                                    _maxScale = _minScale;
                                  }
                                });
                              },
                            ),
                          ),
                          const SizedBox(width: 8),
                          Expanded(
                            child: _numberField(
                              label: 'max',
                              value: _maxScale,
                              onChanged: (v) {
                                final d = double.tryParse(v) ?? _maxScale;
                                setState(() {
                                  _maxScale = d.clamp(0.1, 20.0);
                                  if (_minScale > _maxScale) {
                                    _minScale = _maxScale;
                                  }
                                });
                              },
                            ),
                          ),
                        ],
                      ),

                      const SizedBox(height: 12),
                      Text(
                        'Mouse/Trackpad scale factor (${_scaleFactor.toStringAsFixed(0)})',
                      ),
                      Slider(
                        min: 50,
                        max: 600,
                        divisions: 11,
                        value: _scaleFactor,
                        onChanged: (v) => setState(() => _scaleFactor = v),
                      ),
                    ],
                  ),
                ),
              ),
              const VerticalDivider(width: 1),
              Expanded(
                child: Container(
                  color: Theme.of(context).colorScheme.surface,
                  child: Center(
                    child: Builder(
                      builder: (_) {
                        final useStandard =
                            _viewer == ViewerImplementation.interactiveViewer;
                        switch (_mode) {
                          case PresentationMode.grid:
                            return grid_demo.GridPresentation(
                              viewport: viewport,
                              transformationController: _tc,
                              allowNonCovering: _allowNonCovering,
                              panAxis: _panAxis,
                              panEnabled: _panEnabled,
                              scaleEnabled: _scaleEnabled,
                              showScrollbars: _showScrollbars,
                              noMouseDragScroll: _noMouseDragScroll,
                              scaleFactor: _scaleFactor,
                              minScale: _minScale,
                              maxScale: _maxScale,
                              doubleTapToZoom: _doubleTapToZoom,
                              hAlign: _hAlign,
                              vAlign: _vAlign,
                              doubleTapBehaviour: _doubleTapBehaviour,
                              constrained: _constrained,
                              useStandardViewer: useStandard,
                            );
                          case PresentationMode.logo:
                            return logo_demo.LogoPresentation(
                              transformationController: _tc,
                              allowNonCovering: _allowNonCovering,
                              panAxis: _panAxis,
                              panEnabled: _panEnabled,
                              scaleEnabled: _scaleEnabled,
                              showScrollbars: _showScrollbars,
                              noMouseDragScroll: _noMouseDragScroll,
                              scaleFactor: _scaleFactor,
                              minScale: _minScale,
                              maxScale: _maxScale,
                              doubleTapToZoom: _doubleTapToZoom,
                              hAlign: _hAlign,
                              vAlign: _vAlign,
                              doubleTapBehaviour: _doubleTapBehaviour,
                              constrained: _constrained,
                              useStandardViewer: useStandard,
                            );
                          case PresentationMode.image:
                            return image_demo.ImagePresentation(
                              transformationController: _tc,
                              allowNonCovering: _allowNonCovering,
                              panAxis: _panAxis,
                              panEnabled: _panEnabled,
                              scaleEnabled: _scaleEnabled,
                              showScrollbars: _showScrollbars,
                              noMouseDragScroll: _noMouseDragScroll,
                              scaleFactor: _scaleFactor,
                              minScale: _minScale,
                              maxScale: _maxScale,
                              doubleTapToZoom: _doubleTapToZoom,
                              hAlign: _hAlign,
                              vAlign: _vAlign,
                              doubleTapBehaviour: _doubleTapBehaviour,
                              constrained: _constrained,
                              useStandardViewer: useStandard,
                            );
                        }
                      },
                    ),
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
  }

  Widget _boolTile({
    required String title,
    String? subtitle,
    required bool value,
    required ValueChanged<bool> onChanged,
  }) {
    return SwitchListTile(
      contentPadding: EdgeInsets.zero,
      title: Text(title),
      subtitle: subtitle == null ? null : Text(subtitle),
      value: value,
      onChanged: onChanged,
    );
  }

  Widget _numberField({
    required String label,
    required double value,
    required ValueChanged<String> onChanged,
  }) {
    return TextField(
      decoration: InputDecoration(
        labelText: label,
        border: const OutlineInputBorder(),
        isDense: true,
      ),
      controller: TextEditingController(text: value.toStringAsFixed(2)),
      keyboardType: const TextInputType.numberWithOptions(decimal: true),
      onSubmitted: onChanged,
    );
  }
}
20
likes
150
points
3.87k
downloads

Publisher

verified publisherhenrisauer.dev

Weekly Downloads

A Flutter InteractiveViewer replacement with support for scrollbars, double-tap zoom and overall improved zoom handling

Repository (GitHub)

Documentation

API reference

License

BSD-3-Clause (license)

Dependencies

flutter, vector_math

More

Packages that depend on interactive_viewer_2