Skip to main content

scroll_scene 0.1.1 copy "scroll_scene: ^0.1.1" to clipboard
scroll_scene: ^0.1.1 copied to clipboard

GSAP ScrollTrigger for Flutter: pin sections, scrub timelines by scroll progress, keyframes, stagger, snap points and debug markers. Pure Flutter.

example/lib/main.dart

import 'dart:math' as math;

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

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

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

  @override
  State<ScrollSceneExampleApp> createState() => _ScrollSceneExampleAppState();
}

class _ScrollSceneExampleAppState extends State<ScrollSceneExampleApp> {
  bool _markers = false;

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'scroll_scene',
    debugShowCheckedModeBanner: false,
    theme: buildTheme(),
    home: Scaffold(
      body: LandingPage(markers: _markers ? SceneMarkers.defaults : null),
      floatingActionButton: FloatingActionButton.small(
        tooltip: 'Toggle debug markers',
        backgroundColor: _markers ? kAccent : kCard,
        foregroundColor: kInk,
        onPressed: () => setState(() => _markers = !_markers),
        child: const Icon(Icons.straighten),
      ),
    ),
  );
}

class LandingPage extends StatelessWidget {
  const LandingPage({super.key, this.controller, this.markers});

  final ScrollController? controller;
  final SceneMarkers? markers;

  @override
  Widget build(BuildContext context) => CustomScrollView(
    controller: controller,
    slivers: [
      HeroScene(markers: markers),
      PhoneScene(markers: markers),
      GalleryScene(markers: markers),
      RevealScene(markers: markers),
      SnapScene(markers: markers),
      const SliverToBoxAdapter(child: _Footer()),
    ],
  );
}

class _Footer extends StatelessWidget {
  const _Footer();

  @override
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.fromLTRB(28, 60, 28, 80),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const SectionLabel('that is scroll_scene'),
        const SizedBox(height: 12),
        Text(
          'pub.dev/packages/scroll_scene',
          style: Theme.of(context).textTheme.bodyLarge!.copyWith(color: kInk),
        ),
      ],
    ),
  );
}

const kBg = Color(0xFF0B0B0F);
const kInk = Color(0xFFF4F4F6);
const kMuted = Color(0xFF8A8A96);
const kAccent = Color(0xFF7C5CFF);
const kAccent2 = Color(0xFF34D1BF);
const kAccent3 = Color(0xFFFF6B9D);
const kCard = Color(0xFF16161D);

ThemeData buildTheme({String? fontFamily}) => ThemeData(
  brightness: Brightness.dark,
  fontFamily: fontFamily,
  scaffoldBackgroundColor: kBg,
  colorScheme: const ColorScheme.dark(
    primary: kAccent,
    secondary: kAccent2,
    surface: kCard,
  ),
  textTheme: const TextTheme(
    displayLarge: TextStyle(
      fontSize: 44,
      height: 1.05,
      fontWeight: FontWeight.w800,
      letterSpacing: -1.5,
      color: kInk,
    ),
    headlineMedium: TextStyle(
      fontSize: 28,
      fontWeight: FontWeight.w700,
      letterSpacing: -0.5,
      color: kInk,
    ),
    bodyLarge: TextStyle(fontSize: 16, height: 1.5, color: kMuted),
    labelSmall: TextStyle(
      fontSize: 11,
      letterSpacing: 2,
      fontWeight: FontWeight.w600,
      color: kAccent2,
    ),
  ),
  useMaterial3: true,
);

class SectionLabel extends StatelessWidget {
  const SectionLabel(this.text, {super.key});

  final String text;

  @override
  Widget build(BuildContext context) =>
      Text(text.toUpperCase(), style: Theme.of(context).textTheme.labelSmall);
}

const _heroWords = [
  'Pin.',
  'Scrub.',
  'Snap.',
  'Scroll-driven',
  'motion,',
  'the',
  'Flutter',
  'way.',
];

class HeroScene extends StatelessWidget {
  const HeroScene({super.key, this.markers});

  final SceneMarkers? markers;

  @override
  Widget build(BuildContext context) => SliverScrollScene(
    pinExtent: 1400,
    smoothing: const ScrubSmoothing.lerp(Duration(milliseconds: 90)),
    markers: markers?.copyWith(label: 'hero'),
    builder: (context, scene) => _HeroBody(scene: scene),
  );
}

class _HeroBody extends StatelessWidget {
  const _HeroBody({required this.scene});

  final SceneController scene;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final glow = scene.progress.drive(
      SceneTimeline.offset([
        const Keyframe(0, Offset(-0.6, -0.8)),
        const Keyframe(0.5, Offset(0.7, 0.2), curve: Curves.easeInOut),
        const Keyframe(1, Offset(-0.2, 0.9), curve: Curves.easeInOut),
      ]),
    );
    final glowColor = scene.progress.drive(
      SceneTimeline.color([
        const Keyframe(0, kAccent),
        const Keyframe(0.5, kAccent2),
        const Keyframe(1, kAccent3),
      ]),
    );
    final hint = scene.progress.drive(
      SceneTimeline.number([const Keyframe(0, 1), const Keyframe(0.15, 0)]),
    );
    return Stack(
      fit: StackFit.expand,
      children: [
        AnimatedBuilder(
          animation: scene.progress,
          builder:
              (context, _) => Align(
                alignment: Alignment(glow.value.dx, glow.value.dy),
                child: Container(
                  width: 360,
                  height: 360,
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    gradient: RadialGradient(
                      colors: [
                        glowColor.value.withValues(alpha: 0.55),
                        glowColor.value.withValues(alpha: 0),
                      ],
                    ),
                  ),
                ),
              ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 28),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const SectionLabel('scroll_scene'),
              const SizedBox(height: 18),
              Wrap(
                spacing: 10,
                runSpacing: 2,
                children: [
                  for (var i = 0; i < _heroWords.length; i++)
                    _HeroWord(
                      _heroWords[i],
                      t: scene.progress.drive(
                        Stagger(
                          index: i,
                          count: _heroWords.length,
                          duration: 0.3,
                        ),
                      ),
                      style: theme.textTheme.displayLarge!,
                    ),
                ],
              ),
              const SizedBox(height: 28),
              FadeTransition(
                opacity: hint,
                child: Row(
                  children: [
                    const Icon(Icons.arrow_downward, color: kMuted, size: 16),
                    const SizedBox(width: 8),
                    Text('Scroll to scrub', style: theme.textTheme.bodyLarge),
                  ],
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class _HeroWord extends StatelessWidget {
  const _HeroWord(this.text, {required this.t, required this.style});

  final String text;
  final Animation<double> t;
  final TextStyle style;

  @override
  Widget build(BuildContext context) => AnimatedBuilder(
    animation: t,
    builder: (context, _) {
      final v = Curves.easeOutCubic.transform(t.value);
      return Transform.translate(
        offset: Offset(0, 18 * (1 - v)),
        child: Text(
          text,
          style: style.copyWith(
            color: Color.lerp(kInk.withValues(alpha: 0.18), kInk, v),
          ),
        ),
      );
    },
  );
}

class PhoneScene extends StatelessWidget {
  const PhoneScene({super.key, this.markers});

  final SceneMarkers? markers;

  @override
  Widget build(BuildContext context) => SliverScrollScene(
    pinExtent: 1600,
    smoothing: const ScrubSmoothing.spring(stiffness: 220, damping: 28),
    markers: markers?.copyWith(label: 'phone'),
    builder: (context, scene) => _PhoneBody(scene: scene),
  );
}

class _PhoneBody extends StatelessWidget {
  const _PhoneBody({required this.scene});

  final SceneController scene;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final p = scene.progress;
    final body = p.drive(
      SceneTimeline.offset([
        const Keyframe(0, Offset(0, 420)),
        const Keyframe(0.35, Offset.zero, curve: Curves.easeOutCubic),
      ]),
    );
    final screen = p.drive(
      SceneTimeline.offset([
        const Keyframe(0.2, Offset(-320, -120)),
        const Keyframe(0.55, Offset.zero, curve: Curves.easeOutBack),
      ]),
    );
    final camera = p.drive(
      SceneTimeline.offset([
        const Keyframe(0.35, Offset(260, -260)),
        const Keyframe(0.7, Offset.zero, curve: Curves.easeOutBack),
      ]),
    );
    final buttons = p.drive(
      SceneTimeline.offset([
        const Keyframe(0.5, Offset(240, 160)),
        const Keyframe(0.8, Offset.zero, curve: Curves.easeOutCubic),
      ]),
    );
    final tilt = p.drive(
      SceneTimeline.number([
        const Keyframe(0, -0.35),
        const Keyframe(0.8, 0, curve: Curves.easeOut),
        const Keyframe(1, 0.12, curve: Curves.easeInOut),
      ]),
    );
    final glow = p.drive(
      SceneTimeline.number([
        const Keyframe(0.75, 0),
        const Keyframe(1, 1, curve: Curves.easeIn),
      ]),
    );
    final caption = p.drive(
      SceneTimeline.number([
        const Keyframe(0, 0),
        const Keyframe(0.15, 1),
        const Keyframe(0.75, 1),
        const Keyframe(0.9, 0),
      ]),
    );
    final caption2 = p.drive(
      SceneTimeline.number([const Keyframe(0.85, 0), const Keyframe(1, 1)]),
    );

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 28),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const SectionLabel('pin + keyframes'),
          const SizedBox(height: 12),
          SizedBox(
            height: 72,
            child: Stack(
              children: [
                FadeTransition(
                  opacity: caption,
                  child: Text(
                    'Parts fly in\nas you scroll.',
                    style: theme.textTheme.headlineMedium,
                  ),
                ),
                FadeTransition(
                  opacity: caption2,
                  child: Text(
                    'Assembled.\nPinned for 1600 px.',
                    style: theme.textTheme.headlineMedium,
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(height: 24),
          Expanded(
            child: Center(
              child: AnimatedBuilder(
                animation: p,
                builder:
                    (context, _) => Transform(
                      alignment: Alignment.center,
                      transform:
                          Matrix4.identity()
                            ..setEntry(3, 2, 0.0015)
                            ..rotateY(tilt.value)
                            ..rotateZ(tilt.value * 0.25),
                      child: SizedBox(
                        width: 200,
                        height: 400,
                        child: Stack(
                          clipBehavior: Clip.none,
                          children: [
                            Positioned.fill(
                              child: Opacity(
                                opacity: glow.value,
                                child: Container(
                                  decoration: BoxDecoration(
                                    borderRadius: BorderRadius.circular(44),
                                    boxShadow: [
                                      BoxShadow(
                                        color: kAccent.withValues(alpha: 0.6),
                                        blurRadius: 60,
                                        spreadRadius: 4,
                                      ),
                                    ],
                                  ),
                                ),
                              ),
                            ),
                            _part(body.value, _PhoneBodyPart()),
                            _part(screen.value, _ScreenPart(progress: p.value)),
                            _part(camera.value, const _CameraPart()),
                            _part(buttons.value, const _ButtonsPart()),
                          ],
                        ),
                      ),
                    ),
              ),
            ),
          ),
          const SizedBox(height: 40),
        ],
      ),
    );
  }

  Widget _part(Offset offset, Widget child) => Positioned.fill(
    child: Transform.translate(
      offset: offset,
      child: Opacity(
        opacity: (1 - offset.distance / 500).clamp(0.0, 1.0),
        child: child,
      ),
    ),
  );
}

class _PhoneBodyPart extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Container(
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(44),
      gradient: const LinearGradient(
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
        colors: [Color(0xFF2A2A36), Color(0xFF14141B)],
      ),
      border: Border.all(color: const Color(0xFF3B3B4A), width: 2),
    ),
  );
}

class _ScreenPart extends StatelessWidget {
  const _ScreenPart({required this.progress});

  final double progress;

  @override
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.all(10),
    child: ClipRRect(
      borderRadius: BorderRadius.circular(36),
      child: CustomPaint(painter: _ScreenPainter(progress)),
    ),
  );
}

class _ScreenPainter extends CustomPainter {
  _ScreenPainter(this.t);

  final double t;

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    canvas.drawRect(
      rect,
      Paint()
        ..shader = const LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [Color(0xFF1B1830), Color(0xFF0D0D12)],
        ).createShader(rect),
    );
    final on = ((t - 0.8) / 0.2).clamp(0.0, 1.0);
    if (on == 0) return;
    for (var i = 0; i < 5; i++) {
      final y = 90.0 + i * 46;
      final w = size.width * (0.45 + 0.1 * math.sin(i * 1.7)) * on;
      canvas.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(22, y, w, 14),
          const Radius.circular(7),
        ),
        Paint()
          ..color = (i == 0 ? kAccent : kMuted).withValues(alpha: 0.5 * on),
      );
    }
    canvas.drawCircle(
      Offset(size.width / 2, 48),
      14 * on,
      Paint()..color = kAccent2.withValues(alpha: on),
    );
  }

  @override
  bool shouldRepaint(_ScreenPainter old) => old.t != t;
}

class _CameraPart extends StatelessWidget {
  const _CameraPart();

  @override
  Widget build(BuildContext context) => Align(
    alignment: const Alignment(0, -0.92),
    child: Container(
      width: 56,
      height: 16,
      decoration: BoxDecoration(
        color: const Color(0xFF07070A),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Align(
        alignment: const Alignment(0.6, 0),
        child: Container(
          width: 8,
          height: 8,
          decoration: const BoxDecoration(
            color: Color(0xFF2E3A6B),
            shape: BoxShape.circle,
          ),
        ),
      ),
    ),
  );
}

class _ButtonsPart extends StatelessWidget {
  const _ButtonsPart();

  @override
  Widget build(BuildContext context) => Stack(
    children: [
      Positioned(right: -5, top: 110, child: _pill(60)),
      Positioned(left: -5, top: 90, child: _pill(32)),
      Positioned(left: -5, top: 130, child: _pill(32)),
    ],
  );

  Widget _pill(double h) => Container(
    width: 5,
    height: h,
    decoration: BoxDecoration(
      color: const Color(0xFF4A4A5C),
      borderRadius: BorderRadius.circular(3),
    ),
  );
}

const _galleryCards = [
  ('Pin', 'Stick a section for N px of scroll.', kAccent),
  ('Scrub', 'progress is an Animation<double>.', kAccent2),
  ('Keyframes', 'SceneTimeline + Stagger, declarative.', kAccent3),
  ('Snap', 'Settle to points when scrolling stops.', Color(0xFFFFB347)),
  ('Markers', 'GSAP-style debug overlay.', Color(0xFF5CC8FF)),
];

class GalleryScene extends StatelessWidget {
  const GalleryScene({super.key, this.markers});

  final SceneMarkers? markers;

  @override
  Widget build(BuildContext context) => SliverScrollScene(
    pinExtent: 1800,
    smoothing: const ScrubSmoothing.lerp(Duration(milliseconds: 120)),
    markers: markers?.copyWith(label: 'gallery'),
    builder: (context, scene) => _GalleryBody(scene: scene),
  );
}

class _GalleryBody extends StatelessWidget {
  const _GalleryBody({required this.scene});

  final SceneController scene;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return LayoutBuilder(
      builder: (context, constraints) {
        const cardWidth = 260.0;
        const gap = 20.0;
        final rowWidth = _galleryCards.length * (cardWidth + gap) - gap;
        final travel = (rowWidth - constraints.maxWidth + 56).clamp(
          0.0,
          double.infinity,
        );
        final x = scene.progress.drive(
          Tween<double>(
            begin: 0,
            end: -travel,
          ).chain(CurveTween(curve: Curves.linear)),
        );
        return Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 28),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const SectionLabel('horizontal from vertical'),
                  const SizedBox(height: 12),
                  Text(
                    'Scroll down,\nthe gallery slides across.',
                    style: theme.textTheme.headlineMedium,
                  ),
                ],
              ),
            ),
            const SizedBox(height: 36),
            SizedBox(
              height: 340,
              child: AnimatedBuilder(
                animation: x,
                builder:
                    (context, _) => Stack(
                      clipBehavior: Clip.none,
                      children: [
                        Positioned(
                          left: 28 + x.value,
                          top: 0,
                          child: Row(
                            children: [
                              for (
                                var i = 0;
                                i < _galleryCards.length;
                                i++
                              ) ...[
                                _GalleryCard(
                                  index: i,
                                  width: cardWidth,
                                  progress: scene.progress.value,
                                  travel: travel,
                                  x: x.value,
                                ),
                                if (i != _galleryCards.length - 1)
                                  const SizedBox(width: gap),
                              ],
                            ],
                          ),
                        ),
                      ],
                    ),
              ),
            ),
            const SizedBox(height: 28),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 28),
              child: AnimatedBuilder(
                animation: scene.progress,
                builder:
                    (context, _) => ClipRRect(
                      borderRadius: BorderRadius.circular(2),
                      child: LinearProgressIndicator(
                        value: scene.progress.value,
                        minHeight: 3,
                        backgroundColor: kCard,
                        color: kInk,
                      ),
                    ),
              ),
            ),
          ],
        );
      },
    );
  }
}

class _GalleryCard extends StatelessWidget {
  const _GalleryCard({
    required this.index,
    required this.width,
    required this.progress,
    required this.travel,
    required this.x,
  });

  final int index;
  final double width;
  final double progress;
  final double travel;
  final double x;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final (title, body, color) = _galleryCards[index];
    final center = 28 + x + index * (width + 20) + width / 2;
    final screenCenter = MediaQuery.sizeOf(context).width / 2;
    final focus = (1 - ((center - screenCenter).abs() / 300)).clamp(0.0, 1.0);
    return Transform.scale(
      scale: 0.92 + 0.08 * focus,
      child: Container(
        width: width,
        height: 340,
        padding: const EdgeInsets.all(24),
        decoration: BoxDecoration(
          color: kCard,
          borderRadius: BorderRadius.circular(28),
          border: Border.all(
            color: Color.lerp(const Color(0xFF26262F), color, focus)!,
            width: 1.5,
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Container(
              width: 48,
              height: 48,
              decoration: BoxDecoration(
                color: color.withValues(alpha: 0.18),
                borderRadius: BorderRadius.circular(16),
              ),
              child: Center(
                child: Text(
                  '0${index + 1}',
                  style: TextStyle(color: color, fontWeight: FontWeight.w800),
                ),
              ),
            ),
            const Spacer(),
            Text(title, style: theme.textTheme.headlineMedium),
            const SizedBox(height: 8),
            Text(body, style: theme.textTheme.bodyLarge),
          ],
        ),
      ),
    );
  }
}

const _revealRows = [
  ('Works as a sliver', 'SliverScrollScene inside CustomScrollView.'),
  ('Works in ListView', 'ScrollScene wraps any box child.'),
  ('No setState per tick', 'Progress is a Listenable; children opt in.'),
  ('Reduced motion aware', 'disableAnimations means no smoothing, no springs.'),
];

class RevealScene extends StatelessWidget {
  const RevealScene({super.key, this.markers});

  final SceneMarkers? markers;

  @override
  Widget build(BuildContext context) => SliverScrollScene(
    start: const ScenePosition.top(0.85),
    end: const ScenePosition.bottom(0.75),
    markers: markers?.copyWith(label: 'reveal'),
    builder:
        (context, scene) => Padding(
          padding: const EdgeInsets.fromLTRB(28, 80, 28, 80),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const SectionLabel('reveal, unpinned'),
              const SizedBox(height: 12),
              Text(
                'Triggered by position,\nstaggered by index.',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
              const SizedBox(height: 28),
              for (var i = 0; i < _revealRows.length; i++)
                _RevealRow(
                  index: i,
                  t: scene.progress.drive(
                    Stagger(
                      index: i,
                      count: _revealRows.length,
                      each: 0.18,
                      curve: Curves.easeOutCubic,
                    ),
                  ),
                ),
            ],
          ),
        ),
  );
}

class _RevealRow extends StatelessWidget {
  const _RevealRow({required this.index, required this.t});

  final int index;
  final Animation<double> t;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final (title, body) = _revealRows[index];
    return AnimatedBuilder(
      animation: t,
      builder:
          (context, child) => Opacity(
            opacity: t.value,
            child: Transform.translate(
              offset: Offset(0, 40 * (1 - t.value)),
              child: child,
            ),
          ),
      child: Container(
        margin: const EdgeInsets.only(bottom: 14),
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: kCard,
          borderRadius: BorderRadius.circular(20),
        ),
        child: Row(
          children: [
            Container(
              width: 36,
              height: 36,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: [
                  kAccent,
                  kAccent2,
                  kAccent3,
                  Colors.orange,
                ][index].withValues(alpha: 0.2),
              ),
              child: Icon(
                Icons.check,
                size: 18,
                color: [kAccent, kAccent2, kAccent3, Colors.orange][index],
              ),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    title,
                    style: theme.textTheme.bodyLarge!.copyWith(
                      color: kInk,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                  Text(body, style: theme.textTheme.bodyLarge),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

const _snapSlides = [
  ('Snap points', '[0, 0.5, 1]\nRelease anywhere, land on a slide.', kAccent),
  (
    'Half way',
    'Progress 0.5. The scroll position\nanimated here on its own.',
    kAccent2,
  ),
  ('Done', 'Progress 1. Keep scrolling\nto unpin.', kAccent3),
];

class SnapScene extends StatelessWidget {
  const SnapScene({super.key, this.markers});

  final SceneMarkers? markers;

  @override
  Widget build(BuildContext context) => SliverScrollScene(
    pinExtent: 1200,
    snap: const SceneSnap(points: [0, 0.5, 1]),
    markers: markers?.copyWith(label: 'snap'),
    builder: (context, scene) => _SnapBody(scene: scene),
  );
}

class _SnapBody extends StatelessWidget {
  const _SnapBody({required this.scene});

  final SceneController scene;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return AnimatedBuilder(
      animation: scene.progress,
      builder: (context, _) {
        final p = scene.progress.value * (_snapSlides.length - 1);
        return Padding(
          padding: const EdgeInsets.symmetric(horizontal: 28),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const SectionLabel('snap'),
              const SizedBox(height: 24),
              SizedBox(
                height: 360,
                child: Stack(
                  fit: StackFit.expand,
                  children: [
                    for (var i = 0; i < _snapSlides.length; i++)
                      _SnapSlide(
                        index: i,
                        weight: (1 - (p - i).abs()).clamp(0.0, 1.0),
                        style: theme,
                      ),
                  ],
                ),
              ),
              const SizedBox(height: 28),
              Row(
                children: [
                  for (var i = 0; i < _snapSlides.length; i++)
                    AnimatedContainer(
                      duration: const Duration(milliseconds: 200),
                      margin: const EdgeInsets.only(right: 8),
                      width: (p - i).abs() < 0.5 ? 28 : 8,
                      height: 8,
                      decoration: BoxDecoration(
                        color: (p - i).abs() < 0.5 ? kInk : kMuted,
                        borderRadius: BorderRadius.circular(4),
                      ),
                    ),
                ],
              ),
            ],
          ),
        );
      },
    );
  }
}

class _SnapSlide extends StatelessWidget {
  const _SnapSlide({
    required this.index,
    required this.weight,
    required this.style,
  });

  final int index;
  final double weight;
  final ThemeData style;

  @override
  Widget build(BuildContext context) {
    final (title, body, color) = _snapSlides[index];
    return IgnorePointer(
      child: Opacity(
        opacity: weight,
        child: Transform.scale(
          scale: 0.9 + 0.1 * weight,
          child: Container(
            padding: const EdgeInsets.all(28),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(32),
              gradient: LinearGradient(
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
                colors: [
                  color.withValues(alpha: 0.9),
                  color.withValues(alpha: 0.4),
                ],
              ),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '0${index + 1}',
                  style: style.textTheme.labelSmall!.copyWith(color: kInk),
                ),
                const Spacer(),
                Text(
                  title,
                  style: style.textTheme.displayLarge!.copyWith(fontSize: 36),
                ),
                const SizedBox(height: 12),
                Text(
                  body,
                  style: style.textTheme.bodyLarge!.copyWith(
                    color: kInk.withValues(alpha: 0.85),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
2
likes
160
points
76
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

GSAP ScrollTrigger for Flutter: pin sections, scrub timelines by scroll progress, keyframes, stagger, snap points and debug markers. Pure Flutter.

Repository (GitHub)
View/report issues

Topics

#scroll #animation #scrolltrigger #parallax #sliver

License

MIT (license)

Dependencies

flutter

More

Packages that depend on scroll_scene