resizable_splitter 2.1.2 copy "resizable_splitter: ^2.1.2" to clipboard
resizable_splitter: ^2.1.2 copied to clipboard

Accessible splitter widget for Flutter layouts that need drag-to-resize panels.

example/lib/main.dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'src/package_meta.dart';
import 'stations/a11y.dart';
import 'stations/collapse.dart';
import 'stations/constraints.dart';
import 'stations/ide.dart';
import 'stations/pixel_pin.dart';
import 'stations/snapping.dart';
import 'theme/app_theme.dart';
import 'theme/tokens.dart';
import 'widgets/code_block.dart';
import 'widgets/hero_solver.dart';
import 'widgets/instrument.dart';
import 'widgets/top_bar.dart';

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

/// Root: owns the theme mode and feeds both light and dark [ThemeData] in so the
/// toggle lerps the whole palette.
class SplitterShowcaseApp extends StatefulWidget {
  const SplitterShowcaseApp({super.key});

  @override
  State<SplitterShowcaseApp> createState() => _SplitterShowcaseAppState();
}

class _SplitterShowcaseAppState extends State<SplitterShowcaseApp> {
  ThemeMode _mode = ThemeMode.dark;

  void _toggle() => setState(
    () => _mode = _mode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark,
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: PackageMeta.name,
      debugShowCheckedModeBanner: false,
      themeMode: _mode,
      theme: buildAppTheme(Brightness.light),
      darkTheme: buildAppTheme(Brightness.dark),
      scrollBehavior: const _DesktopScrollBehavior(),
      home: ShowcasePage(
        isDark: _mode == ThemeMode.dark,
        onToggleTheme: _toggle,
      ),
    );
  }
}

/// Enables drag-to-scroll with mouse/trackpad on web and desktop.
class _DesktopScrollBehavior extends MaterialScrollBehavior {
  const _DesktopScrollBehavior();

  @override
  Set<PointerDeviceKind> get dragDevices => {
    PointerDeviceKind.touch,
    PointerDeviceKind.mouse,
    PointerDeviceKind.trackpad,
    PointerDeviceKind.stylus,
  };
}

class ShowcasePage extends StatefulWidget {
  const ShowcasePage({
    super.key,
    required this.isDark,
    required this.onToggleTheme,
  });
  final bool isDark;
  final VoidCallback onToggleTheme;

  @override
  State<ShowcasePage> createState() => _ShowcasePageState();
}

class _ShowcasePageState extends State<ShowcasePage> {
  final ScrollController _scroll = ScrollController();

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

  @override
  Widget build(BuildContext context) {
    final t = context.tokens;
    const sections = <Widget>[
      PixelPinStation(),
      ConstraintsStation(),
      SnappingStation(),
      CollapseStation(),
      IdeStation(),
      A11yStation(),
    ];

    return Scaffold(
      backgroundColor: t.ink,
      body: Stack(
        children: [
          // Faint global drafting grid.
          Positioned.fill(
            child: IgnorePointer(
              child: CustomPaint(
                painter: GridPainter(
                  color: t.textFaint.withValues(alpha: 0.05),
                  step: 32,
                ),
              ),
            ),
          ),
          Positioned.fill(
            child: Scrollbar(
              controller: _scroll,
              child: SingleChildScrollView(
                controller: _scroll,
                child: Column(
                  children: [
                    const SizedBox(height: 60),
                    const HeroSection(),
                    for (var i = 0; i < sections.length; i++)
                      _SectionFrame(scroll: _scroll, child: sections[i]),
                    _SectionFrame(
                      scroll: _scroll,
                      child: const QuickStartSection(),
                    ),
                    const FooterSection(),
                  ],
                ),
              ),
            ),
          ),
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            child: TopBar(
              isDark: widget.isDark,
              onToggleTheme: widget.onToggleTheme,
            ),
          ),
        ],
      ),
    );
  }
}

/// Wraps each section in a centered max-width frame and a scroll-reveal.
class _SectionFrame extends StatelessWidget {
  const _SectionFrame({required this.scroll, required this.child});
  final ScrollController scroll;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(top: Insets.section),
      child: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: Insets.maxContent),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: Insets.xl),
            child: Reveal(scroll: scroll, child: child),
          ),
        ),
      ),
    );
  }
}

/// The thesis hero: the package's idea in a sentence, then the live solver.
class HeroSection extends StatelessWidget {
  const HeroSection({super.key});

  @override
  Widget build(BuildContext context) {
    final t = context.tokens;
    return Stack(
      children: [
        // Soft amber glow behind the hero.
        Positioned(
          top: -120,
          left: 0,
          right: 0,
          height: 460,
          child: IgnorePointer(
            child: Center(
              child: Container(
                width: 720,
                decoration: BoxDecoration(
                  gradient: RadialGradient(
                    colors: [
                      t.signal.withValues(alpha: t.isDark ? 0.10 : 0.14),
                      Colors.transparent,
                    ],
                  ),
                ),
              ),
            ),
          ),
        ),
        Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: Insets.maxContent),
            child: Padding(
              padding: const EdgeInsets.fromLTRB(Insets.xl, 64, Insets.xl, 0),
              child: LayoutBuilder(
                builder: (context, c) {
                  final headSize = c.maxWidth < 560
                      ? 40.0
                      : c.maxWidth < 820
                      ? 54.0
                      : 68.0;
                  return Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      _RevealOnLoad(
                        order: 0,
                        child: Row(
                          children: [
                            SignalDot(color: t.signal, live: true, size: 7),
                            const SizedBox(width: 4),
                            Flexible(
                              child: Text(
                                'ONE SOLVER BEHIND DRAG · KEYS · SNAP · A11Y',
                                style: context.text.eyebrow.copyWith(
                                  color: t.textLo,
                                ),
                              ),
                            ),
                          ],
                        ),
                      ),
                      const SizedBox(height: Insets.lg),
                      _RevealOnLoad(
                        order: 1,
                        child: RichText(
                          text: TextSpan(
                            style: context.text.hero(headSize),
                            children: [
                              const TextSpan(text: 'Store the '),
                              TextSpan(
                                text: 'intent',
                                style: TextStyle(color: t.request),
                              ),
                              const TextSpan(text: '.\nResolve '),
                              TextSpan(
                                text: 'every frame',
                                style: TextStyle(color: t.signal),
                              ),
                              const TextSpan(text: '.'),
                            ],
                          ),
                        ),
                      ),
                      const SizedBox(height: Insets.xl),
                      _RevealOnLoad(
                        order: 2,
                        child: ConstrainedBox(
                          constraints: const BoxConstraints(maxWidth: 640),
                          child: Text(
                            'A two-pane splitter built on one pure constraint solver. You store a '
                            'request - a fraction or a pixel pin. It resolves the on-screen geometry '
                            'every layout pass, so the position you keep can never disagree with the '
                            'pixels you see.',
                            style: context.text.body(
                              16.5,
                              color: t.textLo,
                              h: 1.65,
                            ),
                          ),
                        ),
                      ),
                      const SizedBox(height: Insets.xl),
                      _RevealOnLoad(order: 3, child: const _InstallRow()),
                      const SizedBox(height: Insets.xxl),
                      _RevealOnLoad(order: 4, child: const HeroSolver()),
                      const SizedBox(height: Insets.xl),
                      _RevealOnLoad(order: 5, child: const _ClaimsStrip()),
                    ],
                  );
                },
              ),
            ),
          ),
        ),
      ],
    );
  }
}

class _InstallRow extends StatelessWidget {
  const _InstallRow();

  @override
  Widget build(BuildContext context) {
    final t = context.tokens;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _InstallChip(),
        const SizedBox(height: Insets.md),
        Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              'drag the divider below',
              style: context.text.mono(12, color: t.textFaint),
            ),
            const SizedBox(width: 6),
            Icon(Icons.south_rounded, size: 13, color: t.textFaint),
          ],
        ),
      ],
    );
  }
}

class _InstallChip extends StatefulWidget {
  @override
  State<_InstallChip> createState() => _InstallChipState();
}

class _InstallChipState extends State<_InstallChip> {
  bool _copied = false;

  Future<void> _copy() async {
    await Clipboard.setData(
      const ClipboardData(text: PackageMeta.installCommand),
    );
    if (!mounted) return;
    setState(() => _copied = true);
    await Future<void>.delayed(const Duration(milliseconds: 1400));
    if (mounted) setState(() => _copied = false);
  }

  @override
  Widget build(BuildContext context) {
    final t = context.tokens;
    return MouseRegion(
      cursor: SystemMouseCursors.click,
      child: GestureDetector(
        onTap: _copy,
        behavior: HitTestBehavior.opaque,
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 460),
          child: Container(
            padding: const EdgeInsets.symmetric(
              horizontal: Insets.md,
              vertical: 10,
            ),
            decoration: BoxDecoration(
              color: t.surface,
              borderRadius: BorderRadius.circular(Corner.sm),
              border: Border.all(color: t.lineStrong),
            ),
            child: Row(
              children: [
                Text(
                  '\$',
                  style: context.text.mono(
                    13,
                    color: t.signalText,
                    w: FontWeight.w700,
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    PackageMeta.installCommand,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: context.text.mono(13, color: t.textHi),
                  ),
                ),
                const SizedBox(width: Insets.md),
                Icon(
                  _copied ? Icons.check_rounded : Icons.content_copy_rounded,
                  size: 13,
                  color: _copied ? t.good : t.textLo,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _ClaimsStrip extends StatelessWidget {
  const _ClaimsStrip();

  static const _claims = [
    (
      Icons.straighten_rounded,
      'Pixel-pinned sidebars',
      'survive container resizes',
    ),
    (
      Icons.account_tree_outlined,
      'RenderObject-backed',
      'intrinsic sizing, dry layout',
    ),
    (
      Icons.unfold_less_rounded,
      "Collapse that can't desync",
      'part of the atomic state',
    ),
    (
      Icons.accessibility_new_rounded,
      'Accessible by default',
      'keys, semantics, RTL, haptics',
    ),
  ];

  @override
  Widget build(BuildContext context) {
    final t = context.tokens;
    return LayoutBuilder(
      builder: (context, c) {
        final cols = c.maxWidth < 560 ? 1 : (c.maxWidth < 900 ? 2 : 4);
        return Wrap(
          spacing: Insets.md,
          runSpacing: Insets.md,
          children: [
            for (final claim in _claims)
              SizedBox(
                width: (c.maxWidth - (cols - 1) * Insets.md) / cols,
                child: Container(
                  padding: const EdgeInsets.all(Insets.md),
                  decoration: BoxDecoration(
                    border: Border(
                      top: BorderSide(color: t.lineStrong, width: 1.5),
                    ),
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Icon(claim.$1, size: 18, color: t.signalText),
                      const SizedBox(height: Insets.sm),
                      Text(
                        claim.$2,
                        style: context.text.body(14, w: FontWeight.w600),
                      ),
                      const SizedBox(height: 2),
                      Text(
                        claim.$3,
                        style: context.text.mono(11, color: t.textFaint),
                      ),
                    ],
                  ),
                ),
              ),
          ],
        );
      },
    );
  }
}

/// Closing get-started: install, the minimum usage, and what was on show.
class QuickStartSection extends StatelessWidget {
  const QuickStartSection({super.key});

  static const _yaml = '''dependencies:
  ${PackageMeta.name}: ${PackageMeta.versionConstraint}''';

  static const _dart = '''ResizableSplitter(
  start: const Center(child: Text('Navigation')),
  end: const Center(child: Text('Content')),
  onChanged: (d) =>
      debugPrint('ratio: \${d.effectiveFraction}'),
);''';

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const SectionHeader(
          index: '07',
          eyebrow: 'GET STARTED',
          title: 'Two panes and a callback',
          blurb:
              'That is the whole minimum. The divider starts centered, is keyboard '
              'focusable, exposes slider semantics, and shields platform views '
              'during a drag - with no extra configuration.',
        ),
        const SizedBox(height: Insets.xl),
        LayoutBuilder(
          builder: (context, c) {
            final stacked = c.maxWidth < 820;
            final yaml = CodeBlock(code: _yaml, label: 'pubspec.yaml');
            final dart = CodeBlock(code: _dart, label: 'main.dart');
            if (stacked) {
              return Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  yaml,
                  const SizedBox(height: Insets.md),
                  dart,
                ],
              );
            }
            return Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Expanded(flex: 2, child: yaml),
                const SizedBox(width: Insets.lg),
                Expanded(flex: 3, child: dart),
              ],
            );
          },
        ),
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final t = context.tokens;
    return Padding(
      padding: const EdgeInsets.only(top: Insets.section),
      child: Container(
        decoration: BoxDecoration(
          border: Border(top: BorderSide(color: t.line)),
        ),
        child: Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: Insets.maxContent),
            child: Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: Insets.xl,
                vertical: Insets.xl,
              ),
              child: Wrap(
                crossAxisAlignment: WrapCrossAlignment.center,
                alignment: WrapAlignment.spaceBetween,
                runSpacing: Insets.md,
                children: [
                  Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      const BrandMark(size: 22),
                      const SizedBox(width: Insets.md),
                      Text(
                        PackageMeta.name,
                        style: context.text.mono(
                          13,
                          color: t.textHi,
                          w: FontWeight.w700,
                        ),
                      ),
                      const SizedBox(width: Insets.md),
                      Text(
                        '${PackageMeta.license} licensed',
                        style: context.text.mono(11.5, color: t.textFaint),
                      ),
                    ],
                  ),
                  Text(
                    'Every divider on this page is the package itself.',
                    style: context.text.mono(11, color: t.textFaint),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

// ===========================================================================
// Reveal animations - respect reduced motion, never hide content permanently.
// ===========================================================================

/// A page-load reveal that fades and lifts its child, staggered by [order].
class _RevealOnLoad extends StatefulWidget {
  const _RevealOnLoad({required this.order, required this.child});
  final int order;
  final Widget child;

  @override
  State<_RevealOnLoad> createState() => _RevealOnLoadState();
}

class _RevealOnLoadState extends State<_RevealOnLoad> {
  bool _shown = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Future<void>.delayed(Duration(milliseconds: 70 * widget.order), () {
        if (mounted) setState(() => _shown = true);
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    if (MediaQuery.of(context).disableAnimations) return widget.child;
    return AnimatedSlide(
      offset: _shown ? Offset.zero : const Offset(0, 0.06),
      duration: Motion.slow,
      curve: Motion.enter,
      child: AnimatedOpacity(
        opacity: _shown ? 1 : 0,
        duration: Motion.slow,
        curve: Motion.enter,
        child: widget.child,
      ),
    );
  }
}

/// Reveals its child the first time it scrolls within viewport. Defaults to
/// visible if motion is disabled or the geometry can't be measured.
class Reveal extends StatefulWidget {
  const Reveal({super.key, required this.scroll, required this.child});
  final ScrollController scroll;
  final Widget child;

  @override
  State<Reveal> createState() => _RevealState();
}

class _RevealState extends State<Reveal> {
  final GlobalKey _key = GlobalKey();
  bool _shown = false;

  @override
  void initState() {
    super.initState();
    widget.scroll.addListener(_check);
    WidgetsBinding.instance.addPostFrameCallback((_) => _check());
  }

  @override
  void dispose() {
    widget.scroll.removeListener(_check);
    super.dispose();
  }

  void _check() {
    if (_shown || !mounted) return;
    final ctx = _key.currentContext;
    if (ctx == null) return;
    final box = ctx.findRenderObject() as RenderBox?;
    if (box == null || !box.hasSize) return;
    final pos = box.localToGlobal(Offset.zero).dy;
    final viewport = MediaQuery.of(context).size.height;
    if (pos < viewport - 80) {
      setState(() => _shown = true);
      widget.scroll.removeListener(_check);
    }
  }

  @override
  Widget build(BuildContext context) {
    if (MediaQuery.of(context).disableAnimations) {
      return KeyedSubtree(key: _key, child: widget.child);
    }
    return KeyedSubtree(
      key: _key,
      child: AnimatedSlide(
        offset: _shown ? Offset.zero : const Offset(0, 0.04),
        duration: Motion.slow,
        curve: Motion.enter,
        child: AnimatedOpacity(
          opacity: _shown ? 1 : 0,
          duration: Motion.slow,
          curve: Motion.enter,
          child: widget.child,
        ),
      ),
    );
  }
}
2
likes
160
points
173
downloads

Documentation

Documentation
API reference

Publisher

verified publishertomars.tech

Weekly Downloads

Accessible splitter widget for Flutter layouts that need drag-to-resize panels.

Repository (GitHub)
View/report issues

Topics

#layout #divider #split-view #drag #resize

License

MIT (license)

Dependencies

equatable, flutter, meta

More

Packages that depend on resizable_splitter