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

SwiftUI-style scrollTransition and visualEffect for Flutter. Fade, scale, rotate, blur or slide list items by their viewport position without rebuilds.

example/lib/main.dart

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:scroll_transition/scroll_transition.dart';

void main() => runApp(const ScrollTransitionExampleApp());

/// The example app: a catalogue of demos, one per feature.
class ScrollTransitionExampleApp extends StatelessWidget {
  /// Creates the app.
  const ScrollTransitionExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'scroll_transition',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      darkTheme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        brightness: Brightness.dark,
        useMaterial3: true,
      ),
      home: const DemoCatalogue(),
    );
  }
}

class _Demo {
  const _Demo(this.title, this.subtitle, this.builder);
  final String title;
  final String subtitle;
  final WidgetBuilder builder;
}

final List<_Demo> _demos = <_Demo>[
  _Demo(
    'Fade + scale',
    'ScrollEffects.fade().scale(0.8) on a ListView',
    (_) => const FadeScaleDemo(),
  ),
  _Demo('3D drum', 'rotate3D + fade, vertical list', (_) => const DrumDemo()),
  _Demo(
    'Blur + desaturate grid',
    'GridView with blur(6).saturate(0)',
    (_) => const BlurGridDemo(),
  ),
  _Demo(
    'Carousel (VisualEffect)',
    'Horizontal, geometry-driven scale/tilt, LTR/RTL toggle',
    (_) => const CarouselDemo(),
  ),
  _Demo(
    'PageView',
    'rotate + scale + fade between pages',
    (_) => const PageViewDemo(),
  ),
  _Demo(
    'Reversed chat',
    'reverse: true with slide + fade',
    (_) => const ReversedChatDemo(),
  ),
  _Demo(
    'Animated configuration',
    'Discrete phases animated with duration/curve/threshold',
    (_) => const AnimatedConfigDemo(),
  ),
  _Demo(
    'Builder mode',
    'Arbitrary widgets from the phase; child never rebuilt',
    (_) => const BuilderDemo(),
  ),
  _Demo(
    'Pinned header + topInset',
    'CustomScrollView slivers, items fade under the header',
    (_) => const PinnedHeaderDemo(),
  ),
  _Demo(
    'Nested + keep-alive',
    'Horizontal rows inside a vertical list, state kept alive',
    (_) => const NestedDemo(),
  ),
  _Demo(
    'Live geometry',
    'VisualEffect builder showing rect, phase, centerOffset',
    (_) => const GeometryDemo(),
  ),
];

/// Lists every demo, itself animated with a scroll transition.
class DemoCatalogue extends StatelessWidget {
  /// Creates the catalogue.
  const DemoCatalogue({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('scroll_transition demos')),
      body: ListView.builder(
        padding: const EdgeInsets.symmetric(vertical: 8),
        itemCount: _demos.length,
        itemBuilder: (context, i) {
          final demo = _demos[i];
          return ScrollTransition(
            effect: ScrollEffects.fade(0.2).scale(0.9),
            child: Card(
              margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
              child: ListTile(
                title: Text(demo.title),
                subtitle: Text(demo.subtitle),
                trailing: const Icon(Icons.chevron_right),
                onTap: () => Navigator.of(context).push(
                  MaterialPageRoute<void>(
                    builder: (context) => Scaffold(
                      appBar: AppBar(title: Text(demo.title)),
                      body: demo.builder(context),
                    ),
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

Color _colorFor(int i) =>
    HSLColor.fromAHSL(1, (i * 37) % 360.0, 0.6, 0.55).toColor();

class _ColorTile extends StatelessWidget {
  const _ColorTile({required this.index, this.height = 96});
  final int index;
  final double height;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: height,
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      decoration: BoxDecoration(
        color: _colorFor(index),
        borderRadius: BorderRadius.circular(20),
      ),
      alignment: Alignment.center,
      child: Text(
        'Item $index',
        style: const TextStyle(
          color: Colors.white,
          fontSize: 22,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
}

/// fade().scale(0.8) on a plain ListView.
class FadeScaleDemo extends StatelessWidget {
  /// Creates the demo.
  const FadeScaleDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 60,
      itemBuilder: (context, i) => ScrollTransition(
        effect: ScrollEffects.fade().scale(0.8),
        child: _ColorTile(index: i),
      ),
    );
  }
}

/// A 3D drum: items tilt away as they reach the edges.
class DrumDemo extends StatelessWidget {
  /// Creates the demo.
  const DrumDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 60,
      itemBuilder: (context, i) => ScrollTransition(
        topInset: 80,
        bottomInset: 80,
        effect: ScrollEffects.rotate3D(angle: math.pi / 3).fade(0.3),
        child: _ColorTile(index: i, height: 80),
      ),
    );
  }
}

/// Blur and desaturate cells near the edges of a grid.
class BlurGridDemo extends StatelessWidget {
  /// Creates the demo.
  const BlurGridDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      padding: const EdgeInsets.all(8),
      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 180,
        mainAxisSpacing: 8,
        crossAxisSpacing: 8,
      ),
      itemCount: 120,
      itemBuilder: (context, i) => ScrollTransition(
        topInset: 40,
        bottomInset: 40,
        effect: ScrollEffects.blur(6).saturate(0).scale(0.9),
        child: DecoratedBox(
          decoration: BoxDecoration(
            color: _colorFor(i),
            borderRadius: BorderRadius.circular(16),
          ),
          child: Center(
            child: Text(
              '$i',
              style: const TextStyle(color: Colors.white, fontSize: 28),
            ),
          ),
        ),
      ),
    );
  }
}

/// A horizontal carousel driven by [VisualEffect] geometry, with an RTL
/// toggle.
class CarouselDemo extends StatefulWidget {
  /// Creates the demo.
  const CarouselDemo({super.key});

  @override
  State<CarouselDemo> createState() => _CarouselDemoState();
}

class _CarouselDemoState extends State<CarouselDemo> {
  bool _rtl = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SwitchListTile(
          title: const Text('Right-to-left'),
          subtitle: const Text('Leading edge flips to the right'),
          value: _rtl,
          onChanged: (v) => setState(() => _rtl = v),
        ),
        SizedBox(
          height: 260,
          child: Directionality(
            textDirection: _rtl ? TextDirection.rtl : TextDirection.ltr,
            child: ListView.builder(
              scrollDirection: Axis.horizontal,
              itemCount: 30,
              itemBuilder: (context, i) => VisualEffect(
                effect: (g) {
                  final d = g.centerOffset.clamp(-1.0, 1.0);
                  final sign = g.textDirection == TextDirection.rtl ? -1 : 1;
                  return VisualEffectValues(
                    scale: 1 - 0.25 * d.abs(),
                    rotationY: -0.6 * d * sign,
                    opacity: 1 - 0.5 * d.abs(),
                  );
                },
                child: Container(
                  width: 180,
                  margin: const EdgeInsets.all(12),
                  decoration: BoxDecoration(
                    color: _colorFor(i),
                    borderRadius: BorderRadius.circular(24),
                  ),
                  alignment: Alignment.center,
                  child: Text(
                    'Card $i',
                    style: const TextStyle(color: Colors.white, fontSize: 24),
                  ),
                ),
              ),
            ),
          ),
        ),
        const Padding(
          padding: EdgeInsets.all(16),
          child: Text(
            'VisualEffect(effect: (geometry) => ...) runs at paint time. '
            'geometry.centerOffset is -1..1 from the viewport centre.',
          ),
        ),
      ],
    );
  }
}

/// Pages rotate and shrink as they are swiped away.
class PageViewDemo extends StatelessWidget {
  /// Creates the demo.
  const PageViewDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return PageView.builder(
      itemCount: 10,
      itemBuilder: (context, i) => ScrollTransition(
        effect: ScrollEffects.rotate(math.pi / 10).scale(0.7).fade(0.4),
        child: Container(
          margin: const EdgeInsets.all(32),
          decoration: BoxDecoration(
            color: _colorFor(i * 3),
            borderRadius: BorderRadius.circular(32),
          ),
          alignment: Alignment.center,
          child: Text(
            'Page $i',
            style: const TextStyle(color: Colors.white, fontSize: 40),
          ),
        ),
      ),
    );
  }
}

/// A reversed (chat-style) list: the newest message sits at the bottom.
class ReversedChatDemo extends StatelessWidget {
  /// Creates the demo.
  const ReversedChatDemo({super.key});

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return ListView.builder(
      reverse: true,
      itemCount: 80,
      itemBuilder: (context, i) {
        final mine = i.isEven;
        return ScrollTransition(
          effect: ScrollEffects.slide(24).fade(),
          child: Align(
            alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
            child: Container(
              margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
              padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
              decoration: BoxDecoration(
                color: mine ? scheme.primary : scheme.surfaceContainerHighest,
                borderRadius: BorderRadius.circular(18),
              ),
              child: Text(
                'Message #$i',
                style: TextStyle(
                  color: mine ? scheme.onPrimary : scheme.onSurface,
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

/// The animated configuration: items pop in once half-visible.
class AnimatedConfigDemo extends StatefulWidget {
  /// Creates the demo.
  const AnimatedConfigDemo({super.key});

  @override
  State<AnimatedConfigDemo> createState() => _AnimatedConfigDemoState();
}

class _AnimatedConfigDemoState extends State<AnimatedConfigDemo> {
  double _threshold = 0.5;
  bool _animated = true;

  @override
  Widget build(BuildContext context) {
    final config = _animated
        ? ScrollTransitionConfiguration.animated(
            duration: const Duration(milliseconds: 450),
            curve: Curves.easeOutBack,
            threshold: _threshold,
          )
        : const ScrollTransitionConfiguration.interactive();
    return Column(
      children: [
        SwitchListTile(
          title: const Text('Animated configuration'),
          value: _animated,
          onChanged: (v) => setState(() => _animated = v),
        ),
        ListTile(
          title: Text('Threshold ${(_threshold * 100).round()}% visible'),
          subtitle: Slider(
            value: _threshold,
            onChanged: _animated ? (v) => setState(() => _threshold = v) : null,
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: 60,
            itemBuilder: (context, i) => ScrollTransition(
              configuration: config,
              effect: ScrollEffects.fade().scale(0.6).slide(40),
              child: _ColorTile(index: i),
            ),
          ),
        ),
      ],
    );
  }
}

/// builder: arbitrary widgets from the phase.
class BuilderDemo extends StatelessWidget {
  /// Creates the demo.
  const BuilderDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 60,
      itemBuilder: (context, i) => ScrollTransition(
        builder: (context, child, phase) {
          final label = switch (phase.kind) {
            ScrollPhase.topLeading => 'leaving top',
            ScrollPhase.identity => 'fully visible',
            ScrollPhase.bottomTrailing => 'entering bottom',
          };
          return Stack(
            children: [
              child!,
              Positioned(
                right: 28,
                top: 14,
                child: Chip(
                  label: Text('$label  ${phase.value.toStringAsFixed(2)}'),
                ),
              ),
            ],
          );
        },
        child: _ColorTile(index: i),
      ),
    );
  }
}

/// Slivers with a pinned header. List items fade as they slide under it.
class PinnedHeaderDemo extends StatelessWidget {
  /// Creates the demo.
  const PinnedHeaderDemo({super.key});

  static const double _headerExtent = 72;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return CustomScrollView(
      slivers: [
        SliverPersistentHeader(
          pinned: true,
          delegate: _PinnedHeader(
            extent: _headerExtent,
            child: ScrollTransition(
              // The header stays pinned, so its phase stays identity.
              effect: ScrollEffects.fade(),
              child: Container(
                color: scheme.primaryContainer,
                alignment: Alignment.center,
                child: Text(
                  'Pinned header',
                  style: TextStyle(
                    color: scheme.onPrimaryContainer,
                    fontSize: 20,
                  ),
                ),
              ),
            ),
          ),
        ),
        SliverList.builder(
          itemCount: 50,
          itemBuilder: (context, i) => ScrollTransition(
            topInset: _headerExtent,
            effect: ScrollEffects.fade().scale(0.9),
            child: _ColorTile(index: i),
          ),
        ),
      ],
    );
  }
}

class _PinnedHeader extends SliverPersistentHeaderDelegate {
  _PinnedHeader({required this.extent, required this.child});
  final double extent;
  final Widget child;

  @override
  double get minExtent => extent;
  @override
  double get maxExtent => extent;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) => child;

  @override
  bool shouldRebuild(_PinnedHeader oldDelegate) =>
      oldDelegate.extent != extent || oldDelegate.child != child;
}

/// Horizontal rows inside a vertical list. Each card keeps its counter
/// alive when scrolled away.
class NestedDemo extends StatelessWidget {
  /// Creates the demo.
  const NestedDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 20,
      itemBuilder: (context, row) => ScrollTransition(
        effect: ScrollEffects.fade(0.3),
        child: SizedBox(
          height: 150,
          child: ListView.builder(
            scrollDirection: Axis.horizontal,
            itemCount: 20,
            itemBuilder: (context, col) => ScrollTransition(
              effect: ScrollEffects.scale(0.7).rotate3D(angle: 0.8),
              child: _CounterCard(index: row * 20 + col),
            ),
          ),
        ),
      ),
    );
  }
}

class _CounterCard extends StatefulWidget {
  const _CounterCard({required this.index});
  final int index;

  @override
  State<_CounterCard> createState() => _CounterCardState();
}

class _CounterCardState extends State<_CounterCard>
    with AutomaticKeepAliveClientMixin {
  int _taps = 0;

  @override
  bool get wantKeepAlive => _taps > 0;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return GestureDetector(
      onTap: () {
        setState(() => _taps++);
        updateKeepAlive();
      },
      child: Container(
        width: 130,
        margin: const EdgeInsets.all(8),
        decoration: BoxDecoration(
          color: _colorFor(widget.index),
          borderRadius: BorderRadius.circular(16),
        ),
        alignment: Alignment.center,
        child: Text(
          _taps == 0 ? 'Tap me' : 'Taps: $_taps\n(kept alive)',
          textAlign: TextAlign.center,
          style: const TextStyle(color: Colors.white, fontSize: 16),
        ),
      ),
    );
  }
}

/// VisualEffect's builder shows the live geometry.
class GeometryDemo extends StatelessWidget {
  /// Creates the demo.
  const GeometryDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 40,
      itemBuilder: (context, i) => VisualEffect(
        builder: (context, child, g) {
          final phase = g.phase();
          return Container(
            height: 110,
            margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Color.lerp(
                _colorFor(i),
                Colors.black,
                phase.magnitude * 0.7,
              ),
              borderRadius: BorderRadius.circular(16),
            ),
            child: DefaultTextStyle(
              style: const TextStyle(color: Colors.white, fontSize: 13),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Item $i',
                    style: const TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                  Text(
                    'top ${g.rect.top.toStringAsFixed(1)}  '
                    'viewport ${g.viewportSize.height.toStringAsFixed(0)}',
                  ),
                  Text(
                    'phase ${phase.value.toStringAsFixed(2)} (${phase.kind.name})',
                  ),
                  Text(
                    'centerOffset ${g.centerOffset.toStringAsFixed(2)}  '
                    'visible ${(g.visibleFraction * 100).round()}%',
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}
0
likes
160
points
0
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

SwiftUI-style scrollTransition and visualEffect for Flutter. Fade, scale, rotate, blur or slide list items by their viewport position without rebuilds.

Repository (GitHub)
View/report issues

Topics

#scroll #animation #effects #transition #ui

License

MIT (license)

Dependencies

flutter

More

Packages that depend on scroll_transition