Skip to main content

simple_parallax 2.4.2 copy "simple_parallax: ^2.4.2" to clipboard
simple_parallax: ^2.4.2 copied to clipboard

Parallax widgets for Flutter, in pure Dart with no dependencies. A container mode and a per-item mode, either axis, with a drift, a zoom, a blur and an overlay set on their own.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:simple_parallax/simple_parallax.dart';
import 'package:video_player/video_player.dart';

import 'showcase.dart';
import 'tilt_sensor.dart';

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

const AssetImage _village = AssetImage('assets/images/village.webp');
const AssetImage _hill = AssetImage('assets/images/coline-herbe.webp');
const AssetImage _mountain = AssetImage('assets/images/montagne.webp');
const AssetImage _lake = AssetImage('assets/images/lac.webp');
const AssetImage _cabin = AssetImage('assets/images/cabane.webp');
const AssetImage _pines = AssetImage('assets/images/pins.webp');

/// Every photo, in the order of the walk the copy tells.
const List<AssetImage> _photos = <AssetImage>[
  _village,
  _hill,
  _mountain,
  _lake,
  _cabin,
  _pines,
];

const Color _ink = Color(0xFF14110F);
const Color _card = Color(0xF7FCFAF8);
const Color _dusk = Color(0xFF241C18);
const Color _panel = Color(0xFFF7F4F1);
const Color _muted = Color(0xFF6B635C);
const Color _paper = Color(0xFFF6F1EA);
const Color _white = Color(0xFFFFFDFB);
const Color _accent = Color(0xFFD9692E);

/// One entry of the scrolling content, the same copy the README previews use.
class _Note {
  const _Note(this.dot, this.title, this.detail, {this.photo});

  final Color dot;
  final String title;
  final String detail;

  /// The thumbnail the note shows, or `null` for its dot alone.
  final AssetImage? photo;
}

const List<_Note> _notes = <_Note>[
  _Note(
    Color(0xFF2F6FED),
    'Coastal ridge',
    'Eleven kilometres, four hours, no shade after the pass.',
    photo: _village,
  ),
  _Note(
    Color(0xFFE0446B),
    'Trail notes',
    'Water at the refuge only. The upper section stays icy.',
    photo: _hill,
  ),
  _Note(
    Color(0xFF2FA36B),
    'Gear list',
    'Poles, two litres, a shell. Leave the rope behind.',
    photo: _mountain,
  ),
  _Note(
    Color(0xFFEBB53C),
    'Weather',
    'Clear until the afternoon, then wind from the south.',
    photo: _lake,
  ),
  _Note(
    Color(0xFF7A5AF0),
    'Getting there',
    'Bus at 6.40 from the village, last one back at 19.10.',
    photo: _cabin,
  ),
  _Note(
    Color(0xFFE8734A),
    'Permits',
    'None needed below the col. The reserve asks for one.',
    photo: _pines,
  ),
  _Note(
    Color(0xFF3FB6C4),
    'Signal',
    'Patchy along the ridge, nothing at all in the valley.',
    photo: _hill,
  ),
];

/// The note a row at [index] shows, cycling through [_notes].
_Note _noteAt(int index) => _notes[index % _notes.length];

/// Stands in for the platform's reduced motion setting, so every demo can be
/// seen the way someone who turned it on sees it.
final ValueNotifier<bool> _reduceMotion = ValueNotifier<bool>(false);

/// Every combination the package offers, behind a menu.
class ExampleApp extends StatelessWidget {
  /// Creates the example app.
  const ExampleApp({super.key});

  /// The theme the example is drawn in, shared with the screenshot recorder so
  /// the two cannot drift apart.
  static ThemeData get theme => ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: _accent),
        scaffoldBackgroundColor: _paper,
        useMaterial3: true,
      );

  /// The scroll behaviour the example installs, which adds the mouse to the
  /// devices allowed to drag a list.
  static const ScrollBehavior scrollBehavior = _DragScrollBehavior();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Simple Parallax v2',
      debugShowCheckedModeBanner: false,
      scrollBehavior: scrollBehavior,
      theme: theme,
      // The switch on the menu reaches the demos the way the platform would,
      // through MediaQuery.
      builder: (BuildContext context, Widget? child) =>
          ValueListenableBuilder<bool>(
        valueListenable: _reduceMotion,
        builder: (BuildContext context, bool reduce, Widget? _) => MediaQuery(
          data: MediaQuery.of(context).copyWith(
            disableAnimations:
                reduce || MediaQuery.disableAnimationsOf(context),
          ),
          child: child!,
        ),
      ),
      home: const _Menu(),
    );
  }
}

/// Lets a mouse drag a scroll view.
///
/// Flutter hands dragging to touch, stylus and trackpad, never to a mouse, so a
/// sideways list has nothing to drag with on a desktop.
class _DragScrollBehavior extends MaterialScrollBehavior {
  const _DragScrollBehavior();

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

class _Menu extends StatelessWidget {
  const _Menu();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SimpleParallaxWidget(
        children: <Widget>[
          const _MenuCover(),
          Center(
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 760),
              child: Padding(
                padding: const EdgeInsets.fromLTRB(20, 28, 20, 48),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: const <Widget>[
                    _ReduceMotionSwitch(),
                    _Section('Put together'),
                    _Entry(
                      label: 'A travel page',
                      detail:
                          'Every picture on it drifts, zooms, blurs or follows a row',
                      photo: _mountain,
                      demo: ShowcaseDemo(),
                    ),
                    _Section('Container mode'),
                    _Entry(
                      label: 'One background behind the page',
                      detail: 'A scrolling column over a single drifting image',
                      photo: _hill,
                      demo: ContainerVerticalDemo(),
                    ),
                    _Entry(
                      label: 'Sideways',
                      detail: 'The same, drifting on the horizontal axis',
                      photo: _lake,
                      demo: ContainerHorizontalDemo(),
                    ),
                    _Entry(
                      label: 'Slivers and a controller',
                      detail:
                          'A SliverAppBar over the background, and a way back up',
                      photo: _pines,
                      demo: ContainerSliversDemo(),
                    ),
                    _Entry(
                      label: 'A gradient behind the page',
                      detail: 'No image anywhere, just a widget',
                      photo: null,
                      demo: ContainerCustomDemo(),
                    ),
                    _Section('Item mode'),
                    _Entry(
                      label: 'Blocks that slide their own background',
                      detail: 'Each block drifts as it crosses the screen',
                      photo: _village,
                      demo: ItemVerticalDemo(),
                    ),
                    _Entry(
                      label: 'A carousel in a page',
                      detail: 'Following the row, the page around it, or both',
                      photo: _hill,
                      demo: ItemCarouselDemo(),
                    ),
                    _Entry(
                      label: 'Five hundred blocks',
                      detail: 'Built one at a time as they scroll in',
                      photo: _cabin,
                      demo: ItemBuilderDemo(),
                    ),
                    _Entry(
                      label: 'Widgets as the background',
                      detail: 'A gradient, a tinted image and a video',
                      photo: null,
                      demo: ItemCustomDemo(),
                    ),
                    _Section('Effects'),
                    _Entry(
                      label: 'Zooming and blurring',
                      detail: 'Each one run forwards, then backwards',
                      photo: _mountain,
                      demo: ItemZoomBlurDemo(),
                    ),
                    _Entry(
                      label: 'Stopping at the middle',
                      detail:
                          'An effect that is done halfway, held or sent back',
                      photo: _village,
                      demo: ItemReachDemo(),
                    ),
                    _Entry(
                      label: 'A tint over the image',
                      detail: 'A fixed overlay, darkened, tinted or faded',
                      photo: _cabin,
                      demo: ItemOverlayDemo(),
                    ),
                    _Entry(
                      label: 'Leaning with the pointer',
                      detail: 'Move the mouse over it, or tilt the phone',
                      photo: _lake,
                      demo: TiltDemo(),
                    ),
                    _Entry(
                      label: 'Everything at once',
                      detail:
                          'One carousel, every effect on its blocks, leaning',
                      photo: _pines,
                      demo: EverythingDemo(),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// The top of the menu: the package's name over a photo that drifts as the
/// menu scrolls, the first parallax block of the app.
class _MenuCover extends StatelessWidget {
  const _MenuCover();

  @override
  Widget build(BuildContext context) {
    final bool wide = MediaQuery.sizeOf(context).width >= 760;
    return SimpleParallaxItem(
      image: _mountain,
      height: wide ? 360 : 300,
      alignment: const Alignment(0, -0.2),
      parallax: const ParallaxProperties(overscan: 1.6),
      overlay: const OverlayProperties.gradient(
        LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: <Color>[Color(0x1A000000), Color(0xB3000000)],
        ),
      ),
      child: SafeArea(
        child: Align(
          alignment: Alignment.bottomCenter,
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 760),
            child: Padding(
              padding: const EdgeInsets.fromLTRB(24, 0, 24, 30),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  const Text(
                    'FLUTTER · PURE DART',
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w700,
                      letterSpacing: 3,
                      color: Color(0xFFFFC79E),
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    'Simple Parallax',
                    style: TextStyle(
                      fontSize: wide ? 52 : 40,
                      fontWeight: FontWeight.w800,
                      letterSpacing: -1.6,
                      height: 1.05,
                      color: Colors.white,
                    ),
                  ),
                  const SizedBox(height: 10),
                  const Text(
                    'Two modes, either axis, any image or any widget as the '
                    'background. The drift, the zoom, the blur, the overlay '
                    'and the tilt are each set on their own.',
                    style: TextStyle(
                      fontSize: 15,
                      height: 1.5,
                      color: Color(0xE6FFFFFF),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// A menu entry: a thumbnail, what the screen shows, and the way in.
class _Entry extends StatelessWidget {
  const _Entry({
    required this.label,
    required this.detail,
    required this.photo,
    required this.demo,
  });

  final String label;
  final String detail;

  /// The thumbnail, or `null` for a gradient: the screens with no photo.
  final AssetImage? photo;

  final Widget demo;

  @override
  Widget build(BuildContext context) {
    final AssetImage? photo = this.photo;
    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Material(
        color: _white,
        borderRadius: BorderRadius.circular(16),
        elevation: 2,
        shadowColor: const Color(0x403B2A1A),
        child: InkWell(
          borderRadius: BorderRadius.circular(16),
          onTap: () => Navigator.of(context).push(
            MaterialPageRoute<void>(builder: (BuildContext context) => demo),
          ),
          child: Padding(
            padding: const EdgeInsets.all(10),
            child: Row(
              children: <Widget>[
                ClipRRect(
                  borderRadius: BorderRadius.circular(11),
                  child: SizedBox(
                    width: 64,
                    height: 64,
                    child: photo == null
                        ? const DecoratedBox(
                            decoration: BoxDecoration(
                              gradient: LinearGradient(
                                begin: Alignment.topLeft,
                                end: Alignment.bottomRight,
                                colors: <Color>[
                                  Color(0xFF1B1464),
                                  Color(0xFFB4436C),
                                  Color(0xFFFFB25B),
                                ],
                              ),
                            ),
                          )
                        : Image(
                            image: ResizeImage(photo, width: 192),
                            fit: BoxFit.cover,
                          ),
                  ),
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(
                        label,
                        style: const TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.w600,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(height: 3),
                      Text(
                        detail,
                        style: const TextStyle(fontSize: 13, color: _muted),
                      ),
                    ],
                  ),
                ),
                const Padding(
                  padding: EdgeInsets.only(right: 6),
                  child: Icon(Icons.chevron_right, color: _accent),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

/// A heading over a group of menu entries.
class _Section extends StatelessWidget {
  const _Section(this.label);

  final String label;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(4, 18, 4, 10),
      child: Text(
        label.toUpperCase(),
        style: const TextStyle(
          fontSize: 12,
          fontWeight: FontWeight.w700,
          letterSpacing: 2.4,
          color: _accent,
        ),
      ),
    );
  }
}

/// Turns [_reduceMotion] on and off.
class _ReduceMotionSwitch extends StatelessWidget {
  const _ReduceMotionSwitch();

  @override
  Widget build(BuildContext context) {
    return Material(
      color: _white,
      borderRadius: BorderRadius.circular(14),
      elevation: 2,
      shadowColor: const Color(0x403B2A1A),
      clipBehavior: Clip.antiAlias,
      child: ValueListenableBuilder<bool>(
        valueListenable: _reduceMotion,
        builder: (BuildContext context, bool reduce, Widget? _) =>
            SwitchListTile(
          value: reduce,
          onChanged: (bool value) => _reduceMotion.value = value,
          contentPadding: const EdgeInsets.symmetric(horizontal: 18),
          title: const Text(
            'Reduce motion',
            style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
          ),
          subtitle: const Text(
            'What the demos look like with the system setting on: the '
            'backgrounds hold still and the wheel lands in one step.',
            style: TextStyle(fontSize: 13, color: _muted),
          ),
        ),
      ),
    );
  }
}

/// A demo filling the window, with a way back over it.
class _Screen extends StatelessWidget {
  const _Screen({required this.child, this.color});

  final Widget child;

  /// The page under the demo, or `null` for the app's own.
  ///
  /// The item screens stack blocks of prose on [_panel] between the parallax
  /// blocks, and give the page that colour too. The smooth wheel stops the view
  /// on fractions of a pixel, where the edge of a block is blended with what is
  /// under it: on a page of another colour that shows as a line along every
  /// seam.
  final Color? color;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: color,
      body: Stack(
        children: <Widget>[
          Positioned.fill(child: child),
          Positioned(
            left: 12,
            top: MediaQuery.paddingOf(context).top + 12,
            child: Material(
              color: const Color(0x66000000),
              shape: const CircleBorder(),
              child: InkWell(
                customBorder: const CircleBorder(),
                onTap: () => Navigator.of(context).pop(),
                child: const Padding(
                  padding: EdgeInsets.all(10),
                  child: Icon(
                    Icons.arrow_back,
                    size: 22,
                    color: Color(0xFFFFFFFF),
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// A block as tall as the window, so whatever follows it enters from below.
///
/// An item already on screen when a page opens is partway through its crossing,
/// so it has already spent part of its drift and its zoom. A screenful in front
/// of it is what lets the whole range be seen.
class _Screenful extends StatelessWidget {
  const _Screenful({required this.child}) : _more = true;

  /// The last screenful of a page, with nothing below to scroll to.
  const _Screenful.end({required this.child}) : _more = false;

  final Widget child;

  /// Whether a block follows, which the screen asks to scroll to.
  final bool _more;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: MediaQuery.sizeOf(context).height,
      child: _Large(
        child: Stack(
          fit: StackFit.expand,
          children: <Widget>[
            child,
            if (_more)
              const Positioned(
                left: 0,
                right: 0,
                bottom: 28,
                child: Column(
                  children: <Widget>[
                    Text(
                      'SCROLL',
                      style: TextStyle(
                        fontSize: 11,
                        fontWeight: FontWeight.w700,
                        letterSpacing: 3,
                        color: _accent,
                      ),
                    ),
                    SizedBox(height: 4),
                    Icon(Icons.keyboard_arrow_down, color: _accent),
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }
}

/// Sets the prose below it in large type: a screenful has the room.
class _Large extends InheritedWidget {
  const _Large({required super.child});

  static bool of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<_Large>() != null;

  @override
  bool updateShouldNotify(_Large oldWidget) => false;
}

/// A note as a row, inset so the background shows around it.
class _Row extends StatelessWidget {
  const _Row(this.note);

  final _Note note;

  @override
  Widget build(BuildContext context) {
    final AssetImage? photo = note.photo;
    return Center(
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 580),
        child: Container(
          margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 7),
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: _card,
            borderRadius: BorderRadius.circular(18),
            boxShadow: const <BoxShadow>[
              BoxShadow(
                color: Color(0x33000000),
                blurRadius: 22,
                offset: Offset(0, 8),
              ),
            ],
          ),
          child: Row(
            children: <Widget>[
              if (photo == null)
                Padding(
                  padding: const EdgeInsets.all(10),
                  child: Container(
                    width: 14,
                    height: 14,
                    decoration: BoxDecoration(
                      color: note.dot,
                      shape: BoxShape.circle,
                    ),
                  ),
                )
              else
                ClipRRect(
                  borderRadius: BorderRadius.circular(11),
                  child: Image(
                    image: ResizeImage(photo, width: 168),
                    width: 56,
                    height: 56,
                    fit: BoxFit.cover,
                  ),
                ),
              const SizedBox(width: 16),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      note.title,
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.w700,
                        letterSpacing: -0.2,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 3),
                    Text(
                      note.detail,
                      style: const TextStyle(
                        fontSize: 13,
                        height: 1.4,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(width: 8),
            ],
          ),
        ),
      ),
    );
  }
}

/// A note as a card of the sideways content.
class _Card extends StatelessWidget {
  const _Card(this.note);

  final _Note note;

  @override
  Widget build(BuildContext context) {
    final AssetImage? photo = note.photo;
    return Center(
      child: Container(
        width: 230,
        height: 310,
        margin: const EdgeInsets.symmetric(horizontal: 10),
        clipBehavior: Clip.antiAlias,
        decoration: BoxDecoration(
          color: _card,
          borderRadius: BorderRadius.circular(20),
          boxShadow: const <BoxShadow>[
            BoxShadow(
              color: Color(0x33000000),
              blurRadius: 22,
              offset: Offset(0, 8),
            ),
          ],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            SizedBox(
              height: 140,
              width: double.infinity,
              child: photo == null
                  ? ColoredBox(color: note.dot)
                  : Image(
                      image: ResizeImage(photo, width: 460),
                      fit: BoxFit.cover,
                    ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(20, 18, 20, 0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    note.title,
                    style: const TextStyle(
                      fontSize: 17,
                      fontWeight: FontWeight.w700,
                      letterSpacing: -0.2,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 6),
                  Text(
                    note.detail,
                    style: const TextStyle(
                      fontSize: 13,
                      height: 1.45,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// An overline and a title over a parallax block, on a scrim so they stay
/// readable whatever the background is.
class _Caption extends StatelessWidget {
  const _Caption(this.overline, this.title);

  final String overline;
  final String title;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.center,
          end: Alignment.bottomCenter,
          colors: <Color>[Color(0x00000000), Color(0xB3000000)],
        ),
      ),
      child: Align(
        alignment: Alignment.bottomLeft,
        child: Padding(
          padding: const EdgeInsets.all(26),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                overline,
                style: const TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  letterSpacing: 3,
                  color: Color(0xB8FFFFFF),
                ),
              ),
              const SizedBox(height: 6),
              Text(
                title,
                style: const TextStyle(
                  fontSize: 32,
                  fontWeight: FontWeight.w700,
                  letterSpacing: -0.8,
                  color: Color(0xFFFFFFFF),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// A block of prose between two parallax blocks.
class _Prose extends StatelessWidget {
  const _Prose(this.heading, this.body);

  final String heading;
  final String body;

  @override
  Widget build(BuildContext context) {
    final bool wide = MediaQuery.sizeOf(context).width >= 760;
    final bool large = _Large.of(context);
    return ColoredBox(
      color: _panel,
      child: Center(
        child: ConstrainedBox(
          constraints: BoxConstraints(maxWidth: large ? 720 : 640),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 28),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Container(width: 32, height: 3, color: _accent),
                const SizedBox(height: 14),
                Text(
                  heading,
                  style: TextStyle(
                    fontSize: large ? (wide ? 44 : 32) : (wide ? 30 : 24),
                    height: 1.15,
                    fontWeight: FontWeight.w700,
                    letterSpacing: large ? -1.4 : -0.8,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 10),
                Text(
                  body,
                  style: TextStyle(
                    fontSize: large ? (wide ? 19 : 17) : (wide ? 16 : 15),
                    height: 1.6,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

/// Container mode: one background drifting behind a scrolling column.
///
/// A speed of `1` spends the whole travel over the whole page, so the background
/// uses exactly the travel `overscan` gives it and never runs out of image.
class ContainerVerticalDemo extends StatelessWidget {
  /// Creates the vertical container demo.
  const ContainerVerticalDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return _Screen(
      child: SimpleParallaxContainer(
        image: _hill,
        parallax: const ParallaxProperties(speed: 0.8, overscan: 1.6),
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 28),
          child: Column(
            children: List<Widget>.generate(
              20,
              (int index) => _Row(_noteAt(index)),
            ),
          ),
        ),
      ),
    );
  }
}

/// The same container, scrolling sideways.
///
/// Only `scrollDirection` changes: the background then drifts on the horizontal
/// axis, and `overscan` widens it instead of heightening it.
class ContainerHorizontalDemo extends StatelessWidget {
  /// Creates the horizontal container demo.
  const ContainerHorizontalDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return _Screen(
      child: SimpleParallaxContainer(
        image: _lake,
        scrollDirection: Axis.horizontal,
        parallax: const ParallaxProperties(overscan: 2),
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Row(
            children: List<Widget>.generate(
              16,
              (int index) => _Card(_noteAt(index)),
            ),
          ),
        ),
      ),
    );
  }
}

/// Container mode over slivers, so the content builds as it scrolls, with a
/// controller of the page's own.
///
/// `SimpleParallaxContainer.slivers` takes the content as slivers instead of a
/// single child, which lets a `SliverAppBar` ride over the background and a
/// list build only the rows the viewport needs. The `controller` is what the
/// button in the bar uses to go back to the top. It is a
/// `SmoothScrollController`, which keeps the wheel eased: a plain
/// `ScrollController` would hand the wheel back to the platform.
class ContainerSliversDemo extends StatefulWidget {
  /// Creates the sliver container demo.
  const ContainerSliversDemo({super.key});

  @override
  State<ContainerSliversDemo> createState() => _ContainerSliversDemoState();
}

class _ContainerSliversDemoState extends State<ContainerSliversDemo> {
  final SmoothScrollController _controller = SmoothScrollController();

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

  void _backToTop() {
    if (MediaQuery.disableAnimationsOf(context)) {
      _controller.jumpTo(0);
      return;
    }
    _controller.animateTo(
      0,
      duration: const Duration(milliseconds: 600),
      curve: Curves.easeInOutCubic,
    );
  }

  @override
  Widget build(BuildContext context) {
    return _Screen(
      child: SimpleParallaxContainer.slivers(
        image: _pines,
        controller: _controller,
        parallax: const ParallaxProperties(overscan: 2),
        slivers: <Widget>[
          SliverAppBar(
            title: const Text('Slivers'),
            // Clear of the way back, which floats over the screen.
            titleSpacing: 76,
            backgroundColor: const Color(0x66000000),
            foregroundColor: const Color(0xFFFFFFFF),
            floating: true,
            automaticallyImplyLeading: false,
            actions: <Widget>[
              IconButton(
                tooltip: 'Back to the top',
                icon: const Icon(Icons.arrow_upward),
                onPressed: _backToTop,
              ),
            ],
          ),
          SliverList.builder(
            itemCount: 40,
            itemBuilder: (BuildContext context, int index) =>
                _Row(_noteAt(index)),
          ),
        ],
      ),
    );
  }
}

/// Item mode: each block slides its own background as it crosses the viewport.
///
/// `SimpleParallaxWidget` is a convenience scroll view built on a `SliverList`,
/// so its blocks build as they come into view; a `ListView` works just as well.
class ItemVerticalDemo extends StatelessWidget {
  /// Creates the vertical item demo.
  const ItemVerticalDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const _Screen(
      color: _panel,
      child: SimpleParallaxWidget(
        children: <Widget>[
          SizedBox(
            height: 230,
            child: _Prose(
              'Two days on the ridge',
              'The path leaves the road just past the bridge and climbs steadily '
                  'for the first hour.',
            ),
          ),
          SimpleParallaxItem(
            image: _village,
            height: 430,
            parallax: ParallaxProperties(overscan: 2),
            child: _Caption('DAY ONE', 'Out of the village'),
          ),
          SizedBox(
            height: 230,
            child: _Prose(
              'Where to stop',
              'The refuge sits a little below the col. It fills up quickly in '
                  'August, so book ahead.',
            ),
          ),
          SimpleParallaxItem(
            image: _lake,
            height: 430,
            parallax: ParallaxProperties(speed: 0.4, overscan: 2),
            child: _Caption('DAY TWO', 'Down to the lake'),
          ),
          SizedBox(
            height: 300,
            child: _Prose(
              'Getting back',
              'The last bus leaves at ten past seven, from the same stop.',
            ),
          ),
        ],
      ),
    );
  }
}

/// Items in a horizontal row inside a vertical page, three times.
///
/// In the first row the blocks read their axis from the row, as in any
/// horizontal list, and slide sideways as it is dragged. The second row is the
/// same with `scrollAxis: Axis.vertical`: the blocks look past the row to the
/// page and slide downwards as the page scrolls, like the blocks of a vertical
/// list. The third follows both, sideways with the row through `parallax` and
/// downwards with the page through `crossParallax`, at a gentler speed. Every
/// way they are laid out by the row, so they take a `width`.
class ItemCarouselDemo extends StatelessWidget {
  /// Creates the carousel demo.
  const ItemCarouselDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const _Screen(
      color: _panel,
      child: SimpleParallaxWidget(
        children: <Widget>[
          SizedBox(
            height: 260,
            child: _Prose(
              'Sideways, with the row',
              'Each block below reads its axis from the row it sits in, so it '
                  'slides sideways as the row moves. Drag it.',
            ),
          ),
          _Carousel(),
          SizedBox(
            height: 260,
            child: _Prose(
              'Or down, with the page',
              'The same row, told to follow the page. Dragging the row leaves '
                  'the images alone; scrolling the page slides them.',
            ),
          ),
          _Carousel(scrollAxis: Axis.vertical),
          SizedBox(
            height: 260,
            child: _Prose(
              'Or both at once',
              'Sideways with the row, and downwards with the page at a '
                  'gentler speed. Scroll the page, then drag the row.',
            ),
          ),
          _Carousel(crossParallax: ParallaxProperties(speed: 0.5)),
          SizedBox(
            height: 420,
            child: _Prose(
              'Which one to follow',
              'Left alone, a block follows the nearest scrollable. scrollAxis '
                  'names the one it should follow when they are nested, and '
                  'crossParallax adds the other.',
            ),
          ),
        ],
      ),
    );
  }
}

/// A row of blocks following the scrollable on [scrollAxis], and the one
/// running across it too when there is a [crossParallax].
class _Carousel extends StatelessWidget {
  const _Carousel({this.scrollAxis, this.crossParallax});

  final Axis? scrollAxis;
  final ParallaxProperties? crossParallax;

  static const List<(String, String, AssetImage)> _days =
      <(String, String, AssetImage)>[
    ('DAY ONE', 'Out of the village', _village),
    ('DAY TWO', 'Along the ridge', _hill),
    ('DAY THREE', 'Over the pass', _mountain),
    ('DAY FOUR', 'Down to the lake', _lake),
  ];

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 380,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 24),
        itemCount: _days.length,
        separatorBuilder: (BuildContext context, int index) =>
            const SizedBox(width: 14),
        itemBuilder: (BuildContext context, int index) => SimpleParallaxItem(
          image: _days[index].$3,
          width: 300,
          borderRadius: BorderRadius.circular(18),
          parallax: const ParallaxProperties(overscan: 1.8),
          scrollAxis: scrollAxis,
          crossParallax: crossParallax,
          child: _Caption(_days[index].$1, _days[index].$2),
        ),
      ),
    );
  }
}

/// Five hundred blocks, each created as it scrolls into view.
///
/// `SimpleParallaxWidget.builder` takes an `itemBuilder` instead of a list, so
/// only the blocks near the screen exist at any time, however long the list.
/// Each block shows `placeholderColor` until its image is decoded, and fades
/// the image in over it with `fadeIn`: the first time a block needs the image,
/// before the cache holds it at that size.
class ItemBuilderDemo extends StatelessWidget {
  /// Creates the builder demo.
  const ItemBuilderDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return _Screen(
      color: _panel,
      child: SimpleParallaxWidget.builder(
        itemCount: 500,
        itemBuilder: (BuildContext context, int index) {
          final _Note note = _noteAt(index ~/ 2);
          if (index.isOdd) {
            return SizedBox(
              height: 150,
              child: _Prose(note.title, note.detail),
            );
          }
          return SimpleParallaxItem(
            image: _photos[(index ~/ 2) % _photos.length],
            height: 320,
            parallax: ParallaxProperties(
              speed: index % 4 == 0 ? 1 : 0.5,
              overscan: 1.8,
            ),
            placeholderColor: _dusk,
            fadeIn: const Duration(milliseconds: 400),
            child: _Caption('No. ${index + 1} OF 500', note.title),
          );
        },
      ),
    );
  }
}

/// The two directions of `zoom`, then the two directions of `blur`.
///
/// All four blocks are given the same `overscan`, so what differs between them
/// is the effect and its sign. A positive zoom pushes in as the block crosses,
/// a negative one starts enlarged and comes to rest; a positive blur softens,
/// a negative one sharpens. The captions stay sharp throughout, since only the
/// background is filtered.
///
/// A screenful of prose sits in front of each, so the block enters from below
/// and its whole range is seen. A block already on screen when the page opens
/// has spent part of that range before the first scroll.
class ItemZoomBlurDemo extends StatelessWidget {
  /// Creates the zoom and blur demo.
  const ItemZoomBlurDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const _Screen(
      color: _panel,
      child: SimpleParallaxWidget(
        children: <Widget>[
          _Screenful(
            child: _Prose(
              'The zoom, both ways',
              'The first block holds its drift still, so what you see is the '
                  'zoom and nothing else. It pushes in as the block crosses. '
                  'Scroll on.',
            ),
          ),
          SimpleParallaxItem(
            image: _mountain,
            height: 620,
            parallax: ParallaxProperties(speed: 0, overscan: 1.6),
            zoom: ZoomProperties(0.9),
            child: _Caption('ZOOM 0.9', 'It pushes in'),
          ),
          _Screenful(
            child: _Prose(
              'And the other way',
              'The same range read from the other end. It starts half again '
                  'as large and settles at its own size as the block leaves. '
                  'Neither goes below its own size.',
            ),
          ),
          SimpleParallaxItem(
            image: _lake,
            height: 620,
            parallax: ParallaxProperties(speed: 0, overscan: 1.6),
            zoom: ZoomProperties(-0.9),
            child: _Caption('ZOOM -0.9', 'It comes to rest'),
          ),
          _Screenful(
            child: _Prose(
              'The blur, both ways',
              'A blur reads in logical pixels rather than as a fraction, so '
                  'the figure is a sigma. This one starts at nothing and ends '
                  'at sixteen.',
            ),
          ),
          SimpleParallaxItem(
            image: _hill,
            height: 620,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            blur: BlurProperties(16),
            child: _Caption('BLUR 16', 'It softens'),
          ),
          _Screenful(
            child: _Prose(
              'And the other way',
              'It arrives soft and comes into focus as the block crosses.',
            ),
          ),
          SimpleParallaxItem(
            image: _cabin,
            height: 620,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            blur: BlurProperties(-16),
            child: _Caption('BLUR -16', 'It sharpens'),
          ),
          _Screenful.end(
            child: _Prose(
              'Only the background moves through it',
              'The filter sits under the block content, so a caption, a button '
                  'or a form over a blurred background stays readable.',
            ),
          ),
        ],
      ),
    );
  }
}

/// An effect packed into the first half of the crossing.
///
/// `reach: 0.5` has it finished as the block passes the eye. What happens after
/// that is `back`: held at its far end, or run backwards. The three blocks are
/// the same zoom held, the same zoom sent back, and a negative blur sent back,
/// which arrives soft, clears in passing and goes soft again.
class ItemReachDemo extends StatelessWidget {
  /// Creates the reach demo.
  const ItemReachDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const _Screen(
      color: _panel,
      child: SimpleParallaxWidget(
        children: <Widget>[
          _Screenful(
            child: _Prose(
              'Done by the middle',
              'Left alone an effect is spread over the whole crossing. This '
                  'one is finished as the block passes the eye, and holds '
                  'there on its way out. Scroll on.',
            ),
          ),
          SimpleParallaxItem(
            image: _village,
            height: 620,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            zoom: ZoomProperties(0.6, reach: 0.5),
            child: _Caption('ZOOM 0.6, REACH 0.5', 'In, then held'),
          ),
          _Screenful(
            child: _Prose(
              'Or sent back',
              'The same block and the same figures, with one word more. It '
                  'runs the range backwards instead of holding it.',
            ),
          ),
          SimpleParallaxItem(
            image: _mountain,
            height: 620,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            zoom: ZoomProperties(0.6, reach: 0.5, back: true),
            child: _Caption('BACK', 'In, then out'),
          ),
          _Screenful(
            child: _Prose(
              'The same on a blur',
              'A negative blur arrives soft. Sent back, it clears as the block '
                  'passes the eye and goes soft again on its way out.',
            ),
          ),
          SimpleParallaxItem(
            image: _lake,
            height: 620,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            blur: BlurProperties(-16, reach: 0.5, back: true),
            child: _Caption('BLUR -16, BACK', 'Sharp in passing'),
          ),
          _Screenful.end(
            child: _Prose(
              'The drift is left alone',
              'Only the zoom and the blur are shaped. A background that walked '
                  'back up the block would read as the list scrolling the '
                  'other way.',
            ),
          ),
        ],
      ),
    );
  }
}

/// The three forms of `overlay`, one under the other.
///
/// The overlay is fixed: it stays where it is while the background drifts under
/// it, and it is drawn under the block content, so the captions here sit at full
/// strength over a photo that has been knocked back.
class ItemOverlayDemo extends StatelessWidget {
  /// Creates the overlay demo.
  const ItemOverlayDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const _Screen(
      color: _panel,
      child: SimpleParallaxWidget(
        children: <Widget>[
          _Screenful(
            child: _Prose(
              'Knocking the image back',
              'A photo is rarely the right contrast for text on its own. An '
                  'overlay is a fixed layer of colour over it, under whatever '
                  'the block draws. Scroll on.',
            ),
          ),
          SimpleParallaxItem(
            image: _cabin,
            height: 520,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            overlay: OverlayProperties.darken(0.45),
            child: _Caption('DARKEN 0.45', 'Black at nearly a half'),
          ),
          _Screenful(
            child: _Prose(
              'Or only where it is needed',
              'A gradient dulls the image where the text goes and leaves the '
                  'rest alone. This one darkens the top; the caption brings '
                  'its own scrim at the bottom.',
            ),
          ),
          SimpleParallaxItem(
            image: _hill,
            height: 520,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            overlay: OverlayProperties.gradient(
              LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.center,
                colors: <Color>[Color(0xCC000000), Color(0x00000000)],
              ),
            ),
            child: _Caption('GRADIENT', 'Dark at the top only'),
          ),
          _Screenful(
            child: _Prose(
              'Or a colour of your own',
              'Any colour works, and the opacity is a parameter of its own, so '
                  'a brand colour can be laid over the photo without working '
                  'out an alpha channel by hand.',
            ),
          ),
          SimpleParallaxItem(
            image: _mountain,
            height: 520,
            parallax: ParallaxProperties(speed: 0.5, overscan: 1.6),
            overlay: OverlayProperties(Color(0xFF1A237E), opacity: 0.5),
            child: _Caption('COLOUR', 'Indigo at a half'),
          ),
          _Screenful.end(
            child: _Prose(
              'It does not move',
              'The background drifts under the overlay while the overlay stays '
                  'where it is. That is what makes it read as a treatment of '
                  'the page rather than as part of the photo.',
            ),
          ),
        ],
      ),
    );
  }
}

/// Aims [tilt] at the way the phone leans, next to the mouse that
/// `PointerTilt` aims it at.
///
/// Gravity gives the lean: read from the accelerometer in the app, from the
/// device orientation in a browser. A gyroscope gives how fast the phone turns
/// rather than where it points, and would drift. Safari on iOS only lets a page
/// read how the phone is held once a tap has allowed it, hence [allowButton].
///
/// Nobody holds a phone flat, so the lean is measured from a rest position
/// that slowly follows how the phone is held: tilt it and the background
/// leans, keep it there and the background comes back to the middle. The
/// reading is taken in portrait; a phone turned on its side swaps the axes.
mixin _PhoneTilt<T extends StatefulWidget> on State<T>, TickerProvider {
  /// What the backgrounds of the screen lean with.
  late final TiltController tilt = TiltController(vsync: this);

  StreamSubscription<Offset>? _sensor;

  /// Whether a tap is needed before the phone's tilt can be read.
  bool _mustAsk = false;

  /// Whether the tilt has been read at all.
  bool _read = false;

  /// Waits for a first reading before offering to allow it.
  Timer? _silence;

  /// Gravity across the screen, in g, when the phone is held at rest.
  Offset? _rest;

  @override
  void initState() {
    super.initState();
    _sensor = gravityAcrossScreen().listen(
      _onGravity,
      // No sensor, or no permission to read it: the mouse is all there is.
      onError: (Object _) {},
      cancelOnError: true,
    );
    // Chrome sends the tilt at once. Safari on iOS sends nothing until a tap
    // has allowed it, so a phone that stays silent is offered the button.
    // Chrome has the same request, and needs no answer to it.
    final bool phone = defaultTargetPlatform == TargetPlatform.android ||
        defaultTargetPlatform == TargetPlatform.iOS;
    if (phone && tiltNeedsPermission) {
      _silence = Timer(const Duration(seconds: 1), () {
        if (mounted && !_read) setState(() => _mustAsk = true);
      });
    }
  }

  /// The button that allows reading the tilt, while one is needed.
  Widget? get allowButton {
    if (!_mustAsk) return null;
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 7),
      child: FilledButton(
        onPressed: _allow,
        child: const Text('Allow tilting the phone'),
      ),
    );
  }

  /// From the tap itself: Safari asks only when a tap is what asks. Once
  /// allowed, the readings reach the listener already there.
  void _allow() {
    askForTilt().then((bool _) {
      if (mounted) setState(() => _mustAsk = false);
    });
  }

  void _onGravity(Offset gravity) {
    _read = true;
    // Readings that come late, or once allowed, need no button.
    if (_mustAsk) setState(() => _mustAsk = false);
    // The rest position catches up over a couple of seconds.
    final Offset rest = _rest = Offset.lerp(_rest ?? gravity, gravity, 0.01)!;
    final Offset lean = gravity - rest;
    // Fifteen degrees either way is a full tilt: sin 15 is about a quarter.
    tilt.aim(Offset(-lean.dx, lean.dy) / 0.25);
  }

  @override
  void dispose() {
    _silence?.cancel();
    _sensor?.cancel();
    tilt.dispose();
    super.dispose();
  }
}

/// Container mode leaning with the mouse on a desktop, and with the phone on
/// a phone.
///
/// One `TiltController` takes both: `PointerTilt` aims it at the mouse, and
/// [_PhoneTilt] at the way the phone leans. The controller smooths the two
/// alike.
class TiltDemo extends StatefulWidget {
  /// Creates the tilt demo.
  const TiltDemo({super.key});

  @override
  State<TiltDemo> createState() => _TiltDemoState();
}

class _TiltDemoState extends State<TiltDemo>
    with SingleTickerProviderStateMixin, _PhoneTilt<TiltDemo> {
  @override
  Widget build(BuildContext context) {
    final Widget? allow = allowButton;
    return _Screen(
      child: PointerTilt(
        controller: tilt,
        child: SimpleParallaxContainer(
          image: _mountain,
          parallax: const ParallaxProperties(speed: 0.8, overscan: 1.6),
          tilt: TiltProperties(tilt, distance: 28),
          child: Padding(
            padding: const EdgeInsets.symmetric(vertical: 28),
            child: Column(
              children: <Widget>[
                const _Row(
                  _Note(
                    Color(0xFFE07A3F),
                    'Move the mouse, or tilt the phone',
                    'The background leans the other way, on top of the '
                        'drift. Scroll and lean at once.',
                  ),
                ),
                if (allow != null) allow,
                for (int index = 0; index < 20; index++) _Row(_noteAt(index)),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

/// One block of [EverythingDemo]: its caption, and the effects it takes.
class _Combo {
  const _Combo(
    this.label,
    this.title,
    this.photo, {
    this.parallax = const ParallaxProperties(overscan: 1.8),
    this.cross,
    this.lean,
    this.zoom,
    this.blur,
    this.overlay,
  });

  final String label;
  final String title;
  final AssetImage photo;
  final ParallaxProperties parallax;

  /// The drift with the page, across the row.
  final ParallaxProperties? cross;

  /// How far the background leans at a full tilt, or `null` for no tilt.
  final double? lean;
  final ZoomProperties? zoom;
  final BlurProperties? blur;
  final OverlayProperties? overlay;
}

/// The blocks of the row: four with every effect, each with figures of its
/// own, then two with less of them.
const List<_Combo> _combos = <_Combo>[
  _Combo(
    'ALL · LEAN 20 · BLUR -6',
    'In focus by the middle',
    _village,
    parallax: ParallaxProperties(overscan: 1.8),
    cross: ParallaxProperties(speed: 0.5),
    lean: 20,
    zoom: ZoomProperties(-0.6, reach: 0.5),
    blur: BlurProperties(-6, reach: 0.5),
    overlay: OverlayProperties.gradient(
      LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.center,
        colors: <Color>[Color(0xB3141110), Color(0x00141110)],
      ),
    ),
  ),
  _Combo(
    'ALL · LEAN 12 · BLUR -3',
    'The same, gently',
    _hill,
    parallax: ParallaxProperties(speed: 0.5, overscan: 1.8),
    cross: ParallaxProperties(speed: 0.3),
    lean: 12,
    zoom: ZoomProperties(-0.3, reach: 0.5),
    blur: BlurProperties(-3, reach: 0.5),
    overlay: OverlayProperties.gradient(
      LinearGradient(
        begin: Alignment.center,
        end: Alignment.bottomCenter,
        colors: <Color>[Color(0x00E07A3F), Color(0x99E07A3F)],
      ),
    ),
  ),
  _Combo(
    'ALL · SENT BACK · BLUR -8',
    'Sharp in passing',
    _mountain,
    parallax: ParallaxProperties(speed: 0.8, overscan: 1.8),
    cross: ParallaxProperties(speed: 0.6),
    lean: 24,
    zoom: ZoomProperties(0.5, reach: 0.5, back: true),
    blur: BlurProperties(-8, reach: 0.5, back: true),
    overlay: OverlayProperties.gradient(
      LinearGradient(
        colors: <Color>[Color(0xB3141110), Color(0x00141110)],
      ),
    ),
  ),
  _Combo(
    'ALL · LEAN 36 · REACH 0.3',
    'Sharp well before the middle',
    _lake,
    parallax: ParallaxProperties(overscan: 2),
    cross: ParallaxProperties(speed: 0.9),
    lean: 36,
    zoom: ZoomProperties(-0.9, reach: 0.3),
    blur: BlurProperties(-10, reach: 0.3),
    overlay: OverlayProperties.gradient(
      RadialGradient(
        radius: 0.9,
        colors: <Color>[Color(0x00141110), Color(0xCC141110)],
      ),
    ),
  ),
  _Combo(
    'BOTH AXES · LEAN 24',
    'No zoom, no blur',
    _cabin,
    parallax: ParallaxProperties(speed: 0.8, overscan: 1.8),
    cross: ParallaxProperties(speed: 0.8),
    lean: 24,
    overlay: OverlayProperties.gradient(
      LinearGradient(
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
        colors: <Color>[Color(0x001A237E), Color(0x991A237E)],
      ),
    ),
  ),
  _Combo('THE ROW ONLY', 'For comparison', _pines),
];

/// Every effect at once, on the blocks of one carousel.
///
/// Each of the first four blocks slides with the row and with the page, leans
/// with the phone or the mouse, arrives blurred and comes into focus on its
/// way to the middle of the row as its zoom settles, and wears a gradient,
/// each with figures of its own. The last two
/// take less of it, to compare. Every block leans with the same controller,
/// and only the backgrounds move: the captions and the gradients hold still.
class EverythingDemo extends StatefulWidget {
  /// Creates the demo of every effect at once.
  const EverythingDemo({super.key});

  @override
  State<EverythingDemo> createState() => _EverythingDemoState();
}

class _EverythingDemoState extends State<EverythingDemo>
    with SingleTickerProviderStateMixin, _PhoneTilt<EverythingDemo> {
  @override
  Widget build(BuildContext context) {
    final Widget? allow = allowButton;
    return _Screen(
      color: _panel,
      child: PointerTilt(
        controller: tilt,
        child: SimpleParallaxWidget(
          children: <Widget>[
            const _Screenful(
              child: _Prose(
                'Everything at once',
                'One row of six blocks. The first four take every effect, '
                    'each with figures of its own: they slide with the row '
                    'and with the page, lean with the phone or the mouse, and '
                    'arrive blurred and zoomed, sharp and settled by the '
                    'middle of the row, under a gradient. The last two take '
                    'less. Scroll down.',
              ),
            ),
            if (allow != null) Center(child: allow),
            SizedBox(
              height: 440,
              child: ListView.separated(
                scrollDirection: Axis.horizontal,
                padding: const EdgeInsets.symmetric(horizontal: 24),
                itemCount: _combos.length,
                separatorBuilder: (BuildContext context, int index) =>
                    const SizedBox(width: 14),
                itemBuilder: (BuildContext context, int index) {
                  final _Combo combo = _combos[index];
                  final double? lean = combo.lean;
                  return SimpleParallaxItem(
                    image: combo.photo,
                    width: 280,
                    borderRadius: BorderRadius.circular(18),
                    parallax: combo.parallax,
                    crossParallax: combo.cross,
                    tilt: lean == null
                        ? null
                        : TiltProperties(tilt, distance: lean),
                    zoom: combo.zoom,
                    blur: combo.blur,
                    overlay: combo.overlay,
                    child: _Caption(combo.label, combo.title),
                  );
                },
              ),
            ),
            const _Screenful.end(
              child: _Prose(
                'Drag the row, scroll the page, tilt the phone',
                'Dragging the row slides the backgrounds sideways and brings '
                    'them into focus. Scrolling the page slides them '
                    'down, and the phone or the mouse leans them, all at once.',
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// A gradient in place of the image.
///
/// `background` takes the layer as a widget, so it can be anything that fills
/// the box it is handed. `fit` and `alignment` apply to the image only: a widget
/// is laid out to fill the layer as it stands.
class ContainerCustomDemo extends StatelessWidget {
  /// Creates the gradient background demo.
  const ContainerCustomDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return _Screen(
      child: SimpleParallaxContainer(
        background: const DecoratedBox(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: <Color>[
                Color(0xFF1B1464),
                Color(0xFFB4436C),
                Color(0xFFFFB25B),
              ],
            ),
          ),
        ),
        parallax: const ParallaxProperties(speed: 0.8, overscan: 1.6),
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 28),
          child: Column(
            children: List<Widget>.generate(
              20,
              (int index) => _Row(_noteAt(index)),
            ),
          ),
        ),
      ),
    );
  }
}

/// Item mode with three layers an `ImageProvider` could not express.
///
/// The first block drifts a gradient, the second the asset tinted through a
/// `ColorFiltered`, the third a looping video. Anything wrapping the image is
/// the caller's business, `loadingBuilder` and `errorBuilder` included. The
/// video carries an aspect ratio of its own, so it goes through a `FittedBox` to
/// cover the layer: that is the work `fit: BoxFit.cover` does for an image. The
/// same layers work behind a whole page, in container mode.
class ItemCustomDemo extends StatelessWidget {
  /// Creates the custom background item demo.
  const ItemCustomDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const _Screen(
      color: _panel,
      child: SimpleParallaxWidget(
        children: <Widget>[
          SizedBox(
            height: 230,
            child: _Prose(
              'No image anywhere',
              'The block below drifts a gradient. The background is a widget, '
                  'so it can be anything that fills the box it is handed.',
            ),
          ),
          SimpleParallaxItem(
            height: 430,
            parallax: ParallaxProperties(overscan: 2),
            background: DecoratedBox(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: <Color>[Color(0xFF311B92), Color(0xFF26C6DA)],
                ),
              ),
            ),
            child: _Caption('A GRADIENT', 'The southern pass'),
          ),
          SizedBox(
            height: 230,
            child: _Prose(
              'The asset, tinted',
              'Wrapping the image is the caller business, which is how a '
                  'ColorFiltered gets in front of it.',
            ),
          ),
          SimpleParallaxItem(
            height: 430,
            parallax: ParallaxProperties(overscan: 2),
            background: ColorFiltered(
              colorFilter: ColorFilter.mode(
                Color(0x99311B92),
                BlendMode.srcATop,
              ),
              child: Image(image: _lake, fit: BoxFit.cover),
            ),
            child: _Caption('A TINTED IMAGE', 'Down to the lake'),
          ),
          SizedBox(
            height: 230,
            child: _Prose(
              'A video',
              'The block below owns its player, so the video lives only while '
                  'the block does, and slides the way an image would.',
            ),
          ),
          SimpleParallaxItem(
            height: 470,
            parallax: ParallaxProperties(overscan: 2),
            background: _VideoBackground(),
            child: _Caption('A VIDEO', 'At the refuge'),
          ),
          SizedBox(
            height: 300,
            child: _Prose(
              'Getting back',
              'The last bus leaves at ten past seven, from the same stop.',
            ),
          ),
        ],
      ),
    );
  }
}

/// A looping video sized to cover the layer it is handed.
///
/// It owns its player, so it can be dropped into any background slot. Where
/// `video_player` has no implementation, Windows and Linux at the time of
/// writing, it falls back to a gradient and says so.
class _VideoBackground extends StatefulWidget {
  const _VideoBackground();

  @override
  State<_VideoBackground> createState() => _VideoBackgroundState();
}

class _VideoBackgroundState extends State<_VideoBackground> {
  static final Uri _source = Uri.parse(
    'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
  );

  late final VideoPlayerController _controller;
  bool _ready = false;
  bool _failed = false;

  @override
  void initState() {
    super.initState();
    _controller = VideoPlayerController.networkUrl(_source);
    _start();
  }

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

  Future<void> _start() async {
    try {
      await _controller.initialize();
      await _controller.setLooping(true);
      // Muted, otherwise a browser refuses to start it without a tap.
      await _controller.setVolume(0);
      await _controller.play();
      if (mounted) {
        setState(() => _ready = true);
      }
    } on Object {
      if (mounted) {
        setState(() => _failed = true);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_ready) {
      final Size size = _controller.value.size;
      return FittedBox(
        fit: BoxFit.cover,
        clipBehavior: Clip.hardEdge,
        child: SizedBox(
          width: size.width,
          height: size.height,
          child: VideoPlayer(_controller),
        ),
      );
    }

    return DecoratedBox(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: <Color>[Color(0xFF1B1464), Color(0xFF241C18)],
        ),
      ),
      child: _failed
          ? const Align(
              alignment: Alignment.topCenter,
              child: Padding(
                padding: EdgeInsets.fromLTRB(24, 90, 24, 0),
                child: Text(
                  'No video player on this platform.',
                  style: TextStyle(fontSize: 13, color: Color(0x99FFFFFF)),
                ),
              ),
            )
          : const SizedBox.shrink(),
    );
  }
}
3
likes
160
points
912
downloads
screenshot

Documentation

API reference

Publisher

verified publishercomapps.be

Weekly Downloads

Parallax widgets for Flutter, in pure Dart with no dependencies. A container mode and a per-item mode, either axis, with a drift, a zoom, a blur and an overlay set on their own.

Homepage
Repository (GitHub)
View/report issues

Topics

#parallax #animation #scrolling #ui #sliver

Funding

Consider supporting this project:

www.paypal.com

License

MIT (license)

Dependencies

flutter

More

Packages that depend on simple_parallax